Skip to content

Commit dc1ecc0

Browse files
authored
Download GGUF, package as Docker Model and push it to Hub (#87)
* Use username + pass auth if DOCKER_USERNAME & DOCKER_PASSWORD are defined * Added GHA to package and push a model * Install certs * Don't initialize client twice
1 parent fb5c833 commit dc1ecc0

File tree

5 files changed

+231
-20
lines changed

5 files changed

+231
-20
lines changed

.github/workflows/package-model.yml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: Package Model
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
gguf_file_url:
7+
description: 'URL to the GGUF file (e.g., https://huggingface.co/unsloth/Qwen3-4B-GGUF/resolve/main/Qwen3-4B-Q4_K_M.gguf)'
8+
required: true
9+
type: string
10+
registry_repository:
11+
description: 'OCI Registry repository'
12+
required: true
13+
type: string
14+
default: 'qwen3'
15+
tag:
16+
description: 'Tag for the Docker image (e.g., 4B-Q4_K_M)'
17+
required: true
18+
type: string
19+
input_types:
20+
description: 'Comma-separated input types (e.g., text,embedding,image)'
21+
required: true
22+
type: string
23+
default: 'text'
24+
output_types:
25+
description: 'Comma-separated output types (e.g., text,embedding,image)'
26+
required: true
27+
type: string
28+
default: 'text'
29+
tool_usage:
30+
description: 'Enable tool usage support'
31+
required: false
32+
type: boolean
33+
default: false
34+
license_url:
35+
description: 'Url to the license file'
36+
required: true
37+
type: string
38+
default: 'https://huggingface.co/datasets/choosealicense/licenses/resolve/main/markdown/apache-2.0.md'
39+
40+
permissions:
41+
contents: read
42+
43+
jobs:
44+
package:
45+
runs-on: ubuntu-latest
46+
steps:
47+
- name: Checkout repository
48+
uses: actions/checkout@v4
49+
50+
- name: Login to Docker Hub
51+
uses: docker/login-action@v3
52+
with:
53+
username: ${{ secrets.DOCKER_USER_STAGING }}
54+
password: ${{ secrets.DOCKER_OAT_STAGING }}
55+
56+
- name: Set up Docker Buildx
57+
uses: docker/setup-buildx-action@v3
58+
with:
59+
driver: cloud
60+
endpoint: "${{ secrets.DOCKER_USER_STAGING }}/default"
61+
install: true
62+
63+
- name: Build and push model
64+
uses: docker/build-push-action@v6
65+
with:
66+
context: .
67+
platforms: 'linux/arm64'
68+
build-args: |
69+
GGUF_FILE_URL=${{ inputs.gguf_file_url }}
70+
LICENSE_URL=${{ inputs.license_url }}
71+
HUB_REPOSITORY=${{ secrets.DOCKER_USER_STAGING }}/${{ inputs.registry_repository }}
72+
TAG=${{ inputs.tag }}
73+
secrets: |
74+
DOCKER_USERNAME=${{ secrets.DOCKER_USER_STAGING }}
75+
DOCKER_PASSWORD=${{ secrets.DOCKER_OAT_STAGING }}

Dockerfile

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# syntax=docker/dockerfile:1
2+
3+
ARG GO_VERSION=1.24.2
4+
ARG GIT_VERSION=v2.47.2
5+
ARG UBUNTU_VERSION=24.04
6+
7+
FROM golang:${GO_VERSION}-bookworm AS builder
8+
9+
# Install git for go mod download if needed
10+
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
11+
12+
WORKDIR /app
13+
14+
# Copy go mod/sum first for better caching
15+
COPY --link go.mod go.sum ./
16+
17+
# Download dependencies (with cache mounts)
18+
RUN --mount=type=cache,target=/go/pkg/mod \
19+
--mount=type=cache,target=/root/.cache/go-build \
20+
go mod download
21+
22+
# Copy the rest of the source code
23+
COPY --link . .
24+
25+
# Build the application
26+
RUN make build
27+
28+
FROM ubuntu:${UBUNTU_VERSION} AS downloader
29+
30+
ARG GGUF_FILE_URL
31+
ARG LICENSE_URL
32+
33+
WORKDIR /app
34+
35+
# Install curl for downloading the files
36+
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
37+
38+
# Create model directory and download the GGUF file
39+
RUN mkdir -p /model && \
40+
curl -L "$GGUF_FILE_URL" -o /model/model.gguf
41+
42+
# Create licenses directory and download the license file
43+
RUN mkdir -p /licenses && \
44+
curl -L "$LICENSE_URL" -o /licenses/LICENSE
45+
46+
FROM ubuntu:${UBUNTU_VERSION} AS packager
47+
48+
ARG HUB_REPOSITORY
49+
ARG TAG
50+
51+
COPY --link --from=downloader /model/model.gguf /model/model.gguf
52+
COPY --link --from=downloader /licenses/LICENSE /licenses/LICENSE
53+
COPY --from=builder /app/bin/model-distribution-tool /usr/local/bin/model-distribution-tool
54+
55+
# Install ca-certificates for the model-distribution-tool
56+
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
57+
58+
# Login to Docker Hub using build secrets
59+
RUN --mount=type=secret,id=DOCKER_USERNAME,env=DOCKER_USERNAME \
60+
--mount=type=secret,id=DOCKER_PASSWORD,env=DOCKER_PASSWORD \
61+
model-distribution-tool package \
62+
--licenses /licenses/LICENSE \
63+
/model/model.gguf \
64+
$HUB_REPOSITORY:$TAG

cmd/mdltool/main.go

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,19 @@ func main() {
6262
os.Exit(1)
6363
}
6464

65-
// Create the client
66-
client, err := distribution.NewClient(
65+
// Create the client with auth if environment variables are set
66+
clientOpts := []distribution.Option{
6767
distribution.WithStoreRootPath(absStorePath),
68-
distribution.WithUserAgent("model-distribution-tool/"+version),
69-
)
68+
distribution.WithUserAgent("model-distribution-tool/" + version),
69+
}
70+
71+
if username := os.Getenv("DOCKER_USERNAME"); username != "" {
72+
if password := os.Getenv("DOCKER_PASSWORD"); password != "" {
73+
clientOpts = append(clientOpts, distribution.WithRegistryAuth(username, password))
74+
}
75+
}
7076

77+
client, err := distribution.NewClient(clientOpts...)
7178
if err != nil {
7279
fmt.Fprintf(os.Stderr, "Error creating client: %v\n", err)
7380
os.Exit(1)
@@ -176,10 +183,23 @@ func cmdPackage(args []string) int {
176183
fmt.Fprintf(os.Stderr, "Continuing anyway, but this may cause issues.\n")
177184
}
178185

179-
// Parse the reference
180-
target, err := registry.NewClient(
186+
// Prepare registry client options
187+
registryClientOpts := []registry.ClientOption{
181188
registry.WithUserAgent("model-distribution-tool/" + version),
182-
).NewTarget(reference)
189+
}
190+
191+
// Add auth if available
192+
if username := os.Getenv("DOCKER_USERNAME"); username != "" {
193+
if password := os.Getenv("DOCKER_PASSWORD"); password != "" {
194+
registryClientOpts = append(registryClientOpts, registry.WithAuthConfig(username, password))
195+
}
196+
}
197+
198+
// Create registry client once with all options
199+
registryClient := registry.NewClient(registryClientOpts...)
200+
201+
// Parse the reference
202+
target, err := registryClient.NewTarget(reference)
183203
if err != nil {
184204
fmt.Fprintf(os.Stderr, "Error parsing reference: %v\n", err)
185205
return 1

distribution/client.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ type options struct {
3636
logger *logrus.Entry
3737
transport http.RoundTripper
3838
userAgent string
39+
username string
40+
password string
3941
}
4042

4143
// WithStoreRootPath sets the store root path
@@ -74,6 +76,16 @@ func WithUserAgent(ua string) Option {
7476
}
7577
}
7678

79+
// WithRegistryAuth sets the registry authentication credentials
80+
func WithRegistryAuth(username, password string) Option {
81+
return func(o *options) {
82+
if username != "" && password != "" {
83+
o.username = username
84+
o.password = password
85+
}
86+
}
87+
}
88+
7789
func defaultOptions() *options {
7890
return &options{
7991
logger: logrus.NewEntry(logrus.StandardLogger()),
@@ -100,14 +112,22 @@ func NewClient(opts ...Option) (*Client, error) {
100112
return nil, fmt.Errorf("initializing store: %w", err)
101113
}
102114

115+
// Create registry client options
116+
registryOpts := []registry.ClientOption{
117+
registry.WithTransport(options.transport),
118+
registry.WithUserAgent(options.userAgent),
119+
}
120+
121+
// Add auth if credentials are provided
122+
if options.username != "" && options.password != "" {
123+
registryOpts = append(registryOpts, registry.WithAuthConfig(options.username, options.password))
124+
}
125+
103126
options.logger.Infoln("Successfully initialized store")
104127
return &Client{
105-
store: s,
106-
log: options.logger,
107-
registry: registry.NewClient(
108-
registry.WithTransport(options.transport),
109-
registry.WithUserAgent(options.userAgent),
110-
),
128+
store: s,
129+
log: options.logger,
130+
registry: registry.NewClient(registryOpts...),
111131
}, nil
112132
}
113133

registry/client.go

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Client struct {
2626
transport http.RoundTripper
2727
userAgent string
2828
keychain authn.Keychain
29+
auth authn.Authenticator
2930
}
3031

3132
type ClientOption func(*Client)
@@ -46,6 +47,17 @@ func WithUserAgent(userAgent string) ClientOption {
4647
}
4748
}
4849

50+
func WithAuthConfig(username, password string) ClientOption {
51+
return func(c *Client) {
52+
if username != "" && password != "" {
53+
c.auth = &authn.Basic{
54+
Username: username,
55+
Password: password,
56+
}
57+
}
58+
}
59+
}
60+
4961
func NewClient(opts ...ClientOption) *Client {
5062
client := &Client{
5163
transport: remote.DefaultTransport,
@@ -65,13 +77,22 @@ func (c *Client) Model(ctx context.Context, reference string) (types.ModelArtifa
6577
return nil, NewReferenceError(reference, err)
6678
}
6779

68-
// Return the artifact at the given reference
69-
remoteImg, err := remote.Image(ref,
80+
// Set up authentication options
81+
authOpts := []remote.Option{
7082
remote.WithContext(ctx),
71-
remote.WithAuthFromKeychain(c.keychain),
7283
remote.WithTransport(c.transport),
7384
remote.WithUserAgent(c.userAgent),
74-
)
85+
}
86+
87+
// Use direct auth if provided, otherwise fall back to keychain
88+
if c.auth != nil {
89+
authOpts = append(authOpts, remote.WithAuth(c.auth))
90+
} else {
91+
authOpts = append(authOpts, remote.WithAuthFromKeychain(c.keychain))
92+
}
93+
94+
// Return the artifact at the given reference
95+
remoteImg, err := remote.Image(ref, authOpts...)
7596
if err != nil {
7697
errStr := err.Error()
7798
if strings.Contains(errStr, "UNAUTHORIZED") {
@@ -93,6 +114,7 @@ type Target struct {
93114
transport http.RoundTripper
94115
userAgent string
95116
keychain authn.Keychain
117+
auth authn.Authenticator
96118
}
97119

98120
func (c *Client) NewTarget(tag string) (*Target, error) {
@@ -105,20 +127,30 @@ func (c *Client) NewTarget(tag string) (*Target, error) {
105127
transport: c.transport,
106128
userAgent: c.userAgent,
107129
keychain: c.keychain,
130+
auth: c.auth,
108131
}, nil
109132
}
110133

111134
func (t *Target) Write(ctx context.Context, model types.ModelArtifact, progressWriter io.Writer) error {
112135
pr := progress.NewProgressReporter(progressWriter, progress.PushMsg, nil)
113136
defer pr.Wait()
114137

115-
if err := remote.Write(t.reference, model,
138+
// Set up authentication options
139+
authOpts := []remote.Option{
116140
remote.WithContext(ctx),
117-
remote.WithAuthFromKeychain(t.keychain),
118141
remote.WithTransport(t.transport),
119142
remote.WithUserAgent(t.userAgent),
120143
remote.WithProgress(pr.Updates()),
121-
); err != nil {
144+
}
145+
146+
// Use direct auth if provided, otherwise fall back to keychain
147+
if t.auth != nil {
148+
authOpts = append(authOpts, remote.WithAuth(t.auth))
149+
} else {
150+
authOpts = append(authOpts, remote.WithAuthFromKeychain(t.keychain))
151+
}
152+
153+
if err := remote.Write(t.reference, model, authOpts...); err != nil {
122154
return fmt.Errorf("write to registry %q: %w", t.reference.String(), err)
123155
}
124156
return nil

0 commit comments

Comments
 (0)