Skip to content

Commit 1eb20d7

Browse files
committed
ipfs: add support to pull images by ref
Signed-off-by: abushwang <[email protected]>
1 parent 6378393 commit 1eb20d7

File tree

7 files changed

+466
-5
lines changed

7 files changed

+466
-5
lines changed

docs/ipfs.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,37 @@ user 0m0.556s
140140
sys 0m0.280s
141141
```
142142

143+
### Pulling Container Images via Image Reference
144+
145+
Stargz Snapshotter currently supports pulling container images using an image reference.
146+
147+
For example, the following command uses lazy pulling to download the specified image reference:
148+
```
149+
ctr-remote i rpull --snapshotter=stargz --ipfs ghcr.io/stargz-containers/python:3.9-org
150+
fetching sha256:3b3f42e1... application/vnd.oci.image.index.v1+json
151+
fetching sha256:4afd7ffe... application/vnd.oci.image.manifest.v1+json
152+
fetching sha256:099a9289... application/vnd.oci.image.config.v1+json
153+
```
154+
155+
Alternatively, you can use the following command to download the specified image reference without lazy pulling:
156+
```
157+
ctr-remote i rpull --snapshotter=overlayfs --ipfs ghcr.io/stargz-containers/python:3.9-org
158+
fetching sha256:33ad01f9... application/vnd.oci.image.index.v1+json
159+
fetching sha256:49d6d96d... application/vnd.oci.image.manifest.v1+json
160+
fetching sha256:6f1289b1... application/vnd.oci.image.config.v1+json
161+
fetching sha256:4c25b309... application/vnd.oci.image.layer.v1.tar+gzip
162+
fetching sha256:9476e460... application/vnd.oci.image.layer.v1.tar+gzip
163+
fetching sha256:64c0f10e... application/vnd.oci.image.layer.v1.tar+gzip
164+
fetching sha256:1acf5650... application/vnd.oci.image.layer.v1.tar+gzip
165+
fetching sha256:3fff52a3... application/vnd.oci.image.layer.v1.tar+gzip
166+
fetching sha256:b95c0dd0... application/vnd.oci.image.layer.v1.tar+gzip
167+
fetching sha256:5cf06daf... application/vnd.oci.image.layer.v1.tar+gzip
168+
fetching sha256:419e258e... application/vnd.oci.image.layer.v1.tar+gzip
169+
fetching sha256:942374d5... application/vnd.oci.image.layer.v1.tar+gzip
170+
```
171+
172+
This functionality is also compatible with downloading container images using CID, allowing you to choose your preferred method.
173+
143174
## Appendix 1: Creating IPFS private network
144175

145176
You can create a private IPFS network as described in the official docs.

ipfs/client/client.go

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package client
1818

1919
import (
20+
"bytes"
2021
"encoding/json"
2122
"fmt"
2223
"io"
@@ -27,6 +28,7 @@ import (
2728
"path/filepath"
2829
"strings"
2930

31+
"github.com/containerd/stargz-snapshotter/ipfs/ipnskey"
3032
"github.com/mitchellh/go-homedir"
3133
ma "github.com/multiformats/go-multiaddr"
3234
manet "github.com/multiformats/go-multiaddr/net"
@@ -226,3 +228,243 @@ func GetIPFSAPIAddress(ipfsPath string, scheme string) (string, error) {
226228
}
227229
return iurl, nil
228230
}
231+
232+
// Resolve resolves the IPNS name to its corresponding CID.
233+
func (c *Client) Resolve(ref string) (string, error) {
234+
if c.Address == "" {
235+
return "", fmt.Errorf("specify IPFS API address")
236+
}
237+
238+
peerID, err := c.importKey(ref)
239+
if err != nil {
240+
return "", fmt.Errorf("failed to import key: %w", err)
241+
}
242+
243+
client := c.Client
244+
if client == nil {
245+
client = http.DefaultClient
246+
}
247+
248+
ipfsAPINameResolve := c.Address + "/api/v0/name/resolve"
249+
req, err := http.NewRequest("POST", ipfsAPINameResolve, nil)
250+
if err != nil {
251+
return "", err
252+
}
253+
254+
q := req.URL.Query()
255+
q.Add("arg", "/ipns/"+peerID)
256+
q.Add("nocache", "true")
257+
req.URL.RawQuery = q.Encode()
258+
259+
resp, err := client.Do(req)
260+
if err != nil {
261+
return "", err
262+
}
263+
defer func() {
264+
io.Copy(io.Discard, resp.Body)
265+
resp.Body.Close()
266+
}()
267+
268+
if resp.StatusCode/100 != 2 {
269+
return "", fmt.Errorf("failed to resolve name %v; status code: %v", peerID, resp.StatusCode)
270+
}
271+
272+
// rs represents the information provided by "/api/v0/name/resolve" API of IPFS.
273+
// Please see details at: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-name-resolve
274+
var rs struct {
275+
Path string `json:"Path"`
276+
}
277+
if err := json.NewDecoder(resp.Body).Decode(&rs); err != nil {
278+
return "", err
279+
}
280+
281+
parts := strings.Split(rs.Path, "/")
282+
if len(parts) < 3 || parts[1] != "ipfs" {
283+
return "", fmt.Errorf("invalid resolved path format: %s", rs.Path)
284+
}
285+
286+
// This is compatible to IPFS behaviour: https://docs.ipfs.tech/concepts/ipns/#ipns-keys
287+
return parts[2], nil
288+
}
289+
290+
// Publish publishes the given CID to IPNS using the key associated with the given ref.
291+
// Please see details at: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-name-publish
292+
func (c *Client) Publish(ref string, cid string) error {
293+
if c.Address == "" {
294+
return fmt.Errorf("specify IPFS API address")
295+
}
296+
297+
_, err := c.importKey(ref)
298+
if err != nil {
299+
return fmt.Errorf("failed to import key: %w", err)
300+
}
301+
302+
client := c.Client
303+
if client == nil {
304+
client = http.DefaultClient
305+
}
306+
307+
ipfsAPINamePublish := c.Address + "/api/v0/name/publish"
308+
req, err := http.NewRequest("POST", ipfsAPINamePublish, nil)
309+
if err != nil {
310+
return err
311+
}
312+
313+
q := req.URL.Query()
314+
q.Add("arg", "/ipfs/"+cid)
315+
q.Add("key", ref)
316+
q.Add("allow-offline", "true")
317+
req.URL.RawQuery = q.Encode()
318+
319+
resp, err := client.Do(req)
320+
if err != nil {
321+
return err
322+
}
323+
defer func() {
324+
io.Copy(io.Discard, resp.Body)
325+
resp.Body.Close()
326+
}()
327+
328+
respBody, err := io.ReadAll(resp.Body)
329+
if err != nil {
330+
return fmt.Errorf("failed to read response body: %v", err)
331+
}
332+
333+
if resp.StatusCode/100 != 2 {
334+
return fmt.Errorf("failed to publish; status code: %v, body: %s\n"+
335+
"Request URL: %s", resp.StatusCode, string(respBody), ipfsAPINamePublish)
336+
}
337+
338+
return nil
339+
}
340+
341+
// importKey imports the key pair associated with the given ref into the local IPFS node.
342+
// The ref will be used as the key name in IPFS. If the key already exists, it will return nil.
343+
// Please see details at: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-key-import
344+
func (c *Client) importKey(ref string) (string, error) {
345+
if c.Address == "" {
346+
return "", fmt.Errorf("specify IPFS API address")
347+
}
348+
349+
keyID, err := c.getKeyIDFromIPFS(ref)
350+
if err == nil && keyID != "" {
351+
return keyID, nil
352+
}
353+
354+
keyData, err := ipnskey.GenerateKeyData(ref)
355+
if err != nil {
356+
return "", fmt.Errorf("failed to generate key data: %w", err)
357+
}
358+
359+
body := &bytes.Buffer{}
360+
writer := multipart.NewWriter(body)
361+
362+
safeFilename := strings.ReplaceAll(ref, "/", "_")
363+
safeFilename = strings.ReplaceAll(safeFilename, ":", "_")
364+
365+
part, err := writer.CreateFormFile("file", safeFilename+".pem")
366+
if err != nil {
367+
return "", fmt.Errorf("failed to create form file: %v", err)
368+
}
369+
370+
_, err = part.Write(keyData)
371+
if err != nil {
372+
return "", fmt.Errorf("failed to write key data: %v", err)
373+
}
374+
375+
err = writer.Close()
376+
if err != nil {
377+
return "", fmt.Errorf("failed to close multipart writer: %v", err)
378+
}
379+
380+
encodedKeyname := url.QueryEscape(ref)
381+
ipfsAPIKeyImport := fmt.Sprintf("%s/api/v0/key/import?arg=%s&format=pem-pkcs8-cleartext", c.Address, encodedKeyname)
382+
383+
req, err := http.NewRequest("POST", ipfsAPIKeyImport, body)
384+
if err != nil {
385+
return "", fmt.Errorf("failed to create HTTP request: %v", err)
386+
}
387+
388+
req.Header.Set("Content-Type", writer.FormDataContentType())
389+
390+
client := c.Client
391+
if client == nil {
392+
client = http.DefaultClient
393+
}
394+
395+
resp, err := client.Do(req)
396+
if err != nil {
397+
return "", fmt.Errorf("failed to send request: %v", err)
398+
}
399+
defer resp.Body.Close()
400+
401+
respBody, err := io.ReadAll(resp.Body)
402+
if err != nil {
403+
return "", fmt.Errorf("failed to read response body: %v", err)
404+
}
405+
406+
if resp.StatusCode != http.StatusOK {
407+
return "", fmt.Errorf("IPFS API returned error status: %d, body: %s\nRequest URL: %s", resp.StatusCode, string(respBody), ipfsAPIKeyImport)
408+
}
409+
410+
return c.getKeyIDFromIPFS(ref)
411+
}
412+
413+
// getKeyIDFromIPFS checks if a key with the given name already exists in IPFS
414+
func (c *Client) getKeyIDFromIPFS(name string) (string, error) {
415+
client := c.Client
416+
if client == nil {
417+
client = http.DefaultClient
418+
}
419+
420+
ipfsAPIKeyList := c.Address + "/api/v0/key/list"
421+
req, err := http.NewRequest("POST", ipfsAPIKeyList, nil)
422+
if err != nil {
423+
return "", err
424+
}
425+
426+
resp, err := client.Do(req)
427+
if err != nil {
428+
return "", fmt.Errorf("failed to get key list: %v", err)
429+
}
430+
defer resp.Body.Close()
431+
432+
respBody, err := io.ReadAll(resp.Body)
433+
if err != nil {
434+
return "", fmt.Errorf("failed to read response body: %v", err)
435+
}
436+
437+
if resp.StatusCode != http.StatusOK {
438+
return "", fmt.Errorf("IPFS API returned error status: %d, body: %s\nRequest URL: %s", resp.StatusCode, string(respBody), ipfsAPIKeyList)
439+
}
440+
441+
var result struct {
442+
Keys []struct {
443+
Name string `json:"name"`
444+
ID string `json:"id"`
445+
} `json:"Keys"`
446+
}
447+
448+
if err := json.Unmarshal(respBody, &result); err != nil {
449+
return "", fmt.Errorf("failed to decode response: %v", err)
450+
}
451+
452+
for _, key := range result.Keys {
453+
if key.Name == name {
454+
return key.ID, nil
455+
}
456+
}
457+
458+
return "", fmt.Errorf("key not found: %s", name)
459+
}
460+
461+
func (c *Client) IsRef(s string) bool {
462+
parts := strings.Split(s, "/")
463+
lastPart := parts[len(parts)-1]
464+
465+
if strings.Contains(lastPart, ":") || strings.Contains(lastPart, "@") {
466+
return true
467+
}
468+
469+
return len(parts) >= 2
470+
}

ipfs/client/client_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ package client
2121
import (
2222
"bytes"
2323
"flag"
24+
"fmt"
2425
"io"
26+
"net/http"
2527
"testing"
2628
)
2729

@@ -48,6 +50,7 @@ func TestIPFSClient(t *testing.T) {
4850
}
4951
checkData(t, c, cid, 0, len(sampleString), sampleString, len(sampleString))
5052
checkData(t, c, cid, 10, 4, sampleString[10:14], len(sampleString))
53+
testPublishAndResolve(t, c, cid)
5154
}
5255

5356
func checkData(t *testing.T, c *Client, cid string, off, len int, wantData string, allSize int) {
@@ -75,3 +78,61 @@ func checkData(t *testing.T, c *Client, cid string, off, len int, wantData strin
7578
return
7679
}
7780
}
81+
82+
func testPublishAndResolve(t *testing.T, c *Client, cid string) {
83+
ref := "test/ref:example"
84+
85+
if err := c.Publish(ref, cid); err != nil {
86+
t.Errorf("failed to publish CID: %v", err)
87+
return
88+
}
89+
90+
resolvedCID, err := c.Resolve(ref)
91+
if err != nil {
92+
t.Errorf("failed to resolve ref: %v", err)
93+
return
94+
}
95+
96+
if resolvedCID != cid {
97+
t.Errorf("unexpected resolved CID: got %v, want %v", resolvedCID, cid)
98+
}
99+
100+
// Clean up the imported key
101+
if err := c.removeKey(ref); err != nil {
102+
t.Errorf("failed to remove key: %v", err)
103+
}
104+
}
105+
106+
// removeKey removes the key associated with the given ref
107+
func (c *Client) removeKey(ref string) error {
108+
if c.Address == "" {
109+
return fmt.Errorf("specify IPFS API address")
110+
}
111+
112+
client := c.Client
113+
if client == nil {
114+
client = http.DefaultClient
115+
}
116+
117+
ipfsAPIKeyRemove := c.Address + "/api/v0/key/rm"
118+
req, err := http.NewRequest("POST", ipfsAPIKeyRemove, nil)
119+
if err != nil {
120+
return err
121+
}
122+
123+
q := req.URL.Query()
124+
q.Add("arg", ref)
125+
req.URL.RawQuery = q.Encode()
126+
127+
resp, err := client.Do(req)
128+
if err != nil {
129+
return err
130+
}
131+
defer resp.Body.Close()
132+
133+
if resp.StatusCode != http.StatusOK {
134+
return fmt.Errorf("failed to remove key; status code: %v", resp.StatusCode)
135+
}
136+
137+
return nil
138+
}

ipfs/converter.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,15 @@ func PushWithIPFSPath(ctx context.Context, client *containerd.Client, ref string
7070
if err != nil {
7171
return "", err
7272
}
73-
return iclient.Add(bytes.NewReader(root))
73+
cid, err := iclient.Add(bytes.NewReader(root))
74+
if err != nil {
75+
return "", err
76+
}
77+
if err := iclient.Publish(ref, cid); err != nil {
78+
return "", err
79+
}
80+
81+
return cid, nil
7482
}
7583

7684
func pushBlobHook(client *ipfsclient.Client) converter.ConvertHookFunc {

0 commit comments

Comments
 (0)