Skip to content

Commit ff9ba08

Browse files
committed
feat: Adds support for docker credentials helpers
1 parent 43f577a commit ff9ba08

File tree

8 files changed

+176
-56
lines changed

8 files changed

+176
-56
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "dispenser"
3-
version = "0.10.0"
3+
version = "0.11.0"
44
edition = "2021"
55
license = "MIT"
66

INSTALL.deb.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ wget ...
2121

2222

2323
```sh
24-
sudo apt install ./dispenser-0.10.0.0-0.x86_64.deb
24+
sudo apt install ./dispenser-0.11.0.0.0-0.x86_64.deb
2525
```
2626

2727
You can validate that it was successfully installed by switching to the

INSTALL.redhat.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ wget ...
2323

2424

2525
```sh
26-
sudo dnf install ./dispenser-0.10.0.0-0.x86_64.rpm
26+
sudo dnf install ./dispenser-0.11.0.0.0-0.x86_64.rpm
2727
```
2828

2929
You can validate that it was successfully installed by switching to the

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ Download the latest `.deb` or `.rpm` package from the [releases page](https://gi
3333

3434
```sh
3535
# Download the .deb package
36-
# wget https://github.com/ixpantia/dispenser/releases/download/v0.10.0/dispenser-0.10.0.0-0.x86_64.deb
36+
# wget https://github.com/ixpantia/dispenser/releases/download/v0.11.0/dispenser-0.11.0.0.0-0.x86_64.deb
3737

38-
sudo apt install ./dispenser-0.10.0.0-0.x86_64.deb
38+
sudo apt install ./dispenser-0.11.0.0.0-0.x86_64.deb
3939
```
4040

4141
### RHEL / CentOS / Fedora
@@ -44,7 +44,7 @@ sudo apt install ./dispenser-0.10.0.0-0.x86_64.deb
4444
# Download the .rpm package
4545
# wget ...
4646

47-
sudo dnf install ./dispenser-0.10.0.0-0.x86_64.rpm
47+
sudo dnf install ./dispenser-0.11.0.0.0-0.x86_64.rpm
4848
```
4949

5050
The installation process will:

src/service/docker.rs

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
//! Docker client module using bollard.
22
//!
33
//! This module provides a shared Docker client instance and helper functions
4-
//! for interacting with Docker via the bollard API.
4+
//! for interacting with Docker via the bollard API, including asynchronous
5+
//! credential support from the Docker CLI configuration.
56
7+
use bollard::auth::DockerCredentials;
68
use bollard::Docker;
9+
use serde::Deserialize;
10+
use std::collections::HashMap;
711
use std::sync::OnceLock;
12+
use tokio::io::AsyncWriteExt;
13+
use tokio::process::Command;
814

915
static DOCKER_CLIENT: OnceLock<Docker> = OnceLock::new();
1016

@@ -18,3 +24,156 @@ pub fn get_docker() -> &'static Docker {
1824
Docker::connect_with_local_defaults().expect("Failed to connect to Docker daemon")
1925
})
2026
}
27+
28+
#[derive(Deserialize, Debug)]
29+
struct DockerConfig {
30+
auths: Option<HashMap<String, DockerConfigAuth>>,
31+
#[serde(rename = "credsStore")]
32+
creds_store: Option<String>,
33+
#[serde(rename = "credHelpers")]
34+
cred_helpers: Option<HashMap<String, String>>,
35+
}
36+
37+
#[derive(Deserialize, Debug)]
38+
struct DockerConfigAuth {
39+
auth: Option<String>,
40+
}
41+
42+
#[derive(Deserialize, Debug)]
43+
#[serde(rename_all = "PascalCase")]
44+
struct CredentialHelperResponse {
45+
username: Option<String>,
46+
secret: Option<String>,
47+
}
48+
49+
/// Get Docker credentials for a given registry from the Docker config file (~/.docker/config.json).
50+
/// This supports static auth strings, the global 'credsStore', and per-registry 'credHelpers'.
51+
pub async fn get_credentials(registry: &str) -> Option<DockerCredentials> {
52+
let config_path = std::env::var("DOCKER_CONFIG")
53+
.map(|p| std::path::PathBuf::from(p).join("config.json"))
54+
.or_else(|_| {
55+
std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".docker/config.json"))
56+
})
57+
.ok()?;
58+
59+
let content = tokio::fs::read_to_string(config_path).await.ok()?;
60+
let config: DockerConfig = serde_json::from_str(&content).ok()?;
61+
62+
// 1. Try Credential Helpers (Specific helper for registry)
63+
if let Some(helpers) = &config.cred_helpers {
64+
if let Some(helper_suffix) = helpers.get(registry) {
65+
if let Some(creds) = call_credential_helper(helper_suffix, registry).await {
66+
return Some(creds);
67+
}
68+
}
69+
}
70+
71+
// 2. Try Static Auths in config.json
72+
if let Some(auths) = &config.auths {
73+
let keys_to_check = get_registry_keys(registry);
74+
for key in keys_to_check {
75+
if let Some(auth_entry) = auths.get(&key) {
76+
if let Some(auth) = &auth_entry.auth {
77+
return Some(DockerCredentials {
78+
auth: Some(auth.clone()),
79+
..Default::default()
80+
});
81+
}
82+
}
83+
}
84+
}
85+
86+
// 3. Try Global Credentials Store
87+
if let Some(helper_suffix) = &config.creds_store {
88+
if let Some(creds) = call_credential_helper(helper_suffix, registry).await {
89+
return Some(creds);
90+
}
91+
}
92+
93+
None
94+
}
95+
96+
/// Calls a docker-credential-helper (like 'osxkeychain', 'secretservice', 'wincred')
97+
async fn call_credential_helper(helper_suffix: &str, registry: &str) -> Option<DockerCredentials> {
98+
let helper_cmd = format!("docker-credential-{}", helper_suffix);
99+
let mut child = Command::new(helper_cmd)
100+
.arg("get")
101+
.stdin(std::process::Stdio::piped())
102+
.stdout(std::process::Stdio::piped())
103+
.spawn()
104+
.ok()?;
105+
106+
if let Some(mut stdin) = child.stdin.take() {
107+
let _ = stdin.write_all(registry.as_bytes()).await;
108+
let _ = stdin.flush().await;
109+
}
110+
111+
let output = child.wait_with_output().await.ok()?;
112+
113+
if output.status.success() {
114+
let creds: CredentialHelperResponse = serde_json::from_slice(&output.stdout).ok()?;
115+
if let (Some(username), Some(password)) = (creds.username, creds.secret) {
116+
return Some(DockerCredentials {
117+
username: Some(username),
118+
password: Some(password),
119+
..Default::default()
120+
});
121+
}
122+
}
123+
None
124+
}
125+
126+
/// Generates a list of possible keys in config.json for a given registry
127+
fn get_registry_keys(registry: &str) -> Vec<String> {
128+
let mut keys = vec![registry.to_string()];
129+
130+
if !registry.starts_with("http") {
131+
keys.push(format!("https://{}", registry));
132+
}
133+
134+
if registry == "docker.io"
135+
|| registry == "registry-1.docker.io"
136+
|| registry == "index.docker.io"
137+
{
138+
keys.push("https://index.docker.io/v1/".to_string());
139+
keys.push("index.docker.io/v1/".to_string());
140+
keys.push("https://registry-1.docker.io/v2/".to_string());
141+
}
142+
143+
keys
144+
}
145+
146+
/// Extract the registry part from an image name.
147+
pub fn extract_registry(image: &str) -> &str {
148+
if let Some(slash_pos) = image.find('/') {
149+
let part = &image[..slash_pos];
150+
// If the first part contains a dot or colon, or is "localhost", it's a registry
151+
if part.contains('.') || part.contains(':') || part == "localhost" {
152+
return part;
153+
}
154+
}
155+
"docker.io"
156+
}
157+
158+
/// Parse an image reference into (image, tag) components
159+
pub fn parse_image_reference(image: &str) -> (&str, &str) {
160+
// Handle digest references (image@sha256:...)
161+
if let Some(at_pos) = image.find('@') {
162+
return (&image[..at_pos], &image[at_pos..]);
163+
}
164+
165+
// Handle tag references (image:tag)
166+
// Need to be careful with registry URLs that contain port numbers
167+
// e.g., localhost:5000/myimage:tag
168+
if let Some(colon_pos) = image.rfind(':') {
169+
// Check if the colon is part of a port number in the registry URL
170+
let after_colon = &image[colon_pos + 1..];
171+
// If there's a slash after the colon, it's a port number, not a tag
172+
if !after_colon.contains('/') {
173+
return (&image[..colon_pos], after_colon);
174+
}
175+
}
176+
177+
// No tag specified, use "latest"
178+
(image, "latest")
179+
}

src/service/instance.rs

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,17 @@ impl ServiceInstance {
160160
let docker = get_docker();
161161

162162
// Parse image name and tag
163-
let (image, tag) = parse_image_reference(&self.config.service.image);
163+
let (image, tag) =
164+
crate::service::docker::parse_image_reference(&self.config.service.image);
165+
let registry = crate::service::docker::extract_registry(image);
166+
let credentials = crate::service::docker::get_credentials(registry).await;
164167

165168
let options: CreateImageOptions = CreateImageOptionsBuilder::new()
166169
.from_image(image)
167170
.tag(tag)
168171
.build();
169172

170-
let mut stream = docker.create_image(Some(options), None, None);
173+
let mut stream = docker.create_image(Some(options), None, credentials);
171174

172175
while let Some(result) = stream.next().await {
173176
match result {
@@ -557,27 +560,6 @@ impl ServiceInstance {
557560
}
558561

559562
/// Parse an image reference into (image, tag) components
560-
fn parse_image_reference(image: &str) -> (&str, &str) {
561-
// Handle digest references (image@sha256:...)
562-
if let Some(at_pos) = image.find('@') {
563-
return (&image[..at_pos], &image[at_pos..]);
564-
}
565-
566-
// Handle tag references (image:tag)
567-
// Need to be careful with registry URLs that contain port numbers
568-
// e.g., localhost:5000/myimage:tag
569-
if let Some(colon_pos) = image.rfind(':') {
570-
// Check if the colon is part of a port number in the registry URL
571-
let after_colon = &image[colon_pos + 1..];
572-
// If there's a slash after the colon, it's a port number, not a tag
573-
if !after_colon.contains('/') {
574-
return (&image[..colon_pos], after_colon);
575-
}
576-
}
577-
578-
// No tag specified, use "latest"
579-
(image, "latest")
580-
}
581563
582564
/// Parse memory limit string (e.g., "512m", "2g") to bytes
583565
fn parse_memory_limit(limit: &str) -> i64 {

src/service/manifest.rs

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -101,42 +101,21 @@ impl ImageWatcher {
101101
}
102102
}
103103

104-
/// Parse an image reference into (image, tag) components
105-
fn parse_image_reference(image: &str) -> (&str, &str) {
106-
// Handle digest references (image@sha256:...)
107-
if let Some(at_pos) = image.find('@') {
108-
return (&image[..at_pos], &image[at_pos..]);
109-
}
110-
111-
// Handle tag references (image:tag)
112-
// Need to be careful with registry URLs that contain port numbers
113-
// e.g., localhost:5000/myimage:tag
114-
if let Some(colon_pos) = image.rfind(':') {
115-
// Check if the colon is part of a port number in the registry URL
116-
let after_colon = &image[colon_pos + 1..];
117-
// If there's a slash after the colon, it's a port number, not a tag
118-
if !after_colon.contains('/') {
119-
return (&image[..colon_pos], after_colon);
120-
}
121-
}
122-
123-
// No tag specified, use "latest"
124-
(image, "latest")
125-
}
126-
127104
async fn get_latest_digest(image: &str) -> Result<Sha256> {
128105
let docker = get_docker();
129106

130107
// Parse image name and tag
131-
let (image_name, tag) = parse_image_reference(image);
108+
let (image_name, tag) = crate::service::docker::parse_image_reference(image);
109+
let registry = crate::service::docker::extract_registry(image_name);
110+
let credentials = crate::service::docker::get_credentials(registry).await;
132111

133112
// Pull the latest image using bollard
134113
let options: CreateImageOptions = CreateImageOptionsBuilder::new()
135114
.from_image(image_name)
136115
.tag(tag)
137116
.build();
138117

139-
let mut stream = docker.create_image(Some(options), None, None);
118+
let mut stream = docker.create_image(Some(options), None, credentials);
140119

141120
while let Some(result) = stream.next().await {
142121
match result {

0 commit comments

Comments
 (0)