Skip to content

Commit 475c106

Browse files
committed
feat: enable reading OCI manifest annotations in shim via containerd API
refactor(shim): reuse sessionm atomic patching and read labels from container metadata chore: add todo for decode in config.go chore: add error log Signed-off-by: Jiwoo Ahn <ikwydls1314@gmail.com>
1 parent 154fc9e commit 475c106

6 files changed

Lines changed: 263 additions & 19 deletions

File tree

.github/linters/urunc-dict.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ iface
183183
ifaces
184184
ifname
185185
imagesapi
186+
imagespec
186187
initpipe
187188
initrds
188189
inlinehilite
@@ -294,6 +295,7 @@ rumprun
294295
runbindable
295296
runp
296297
runtimeclasses
298+
runtimespec
297299
sandboxing
298300
scontroller
299301
seabios
@@ -328,6 +330,7 @@ traefik
328330
triger
329331
ttrpc
330332
twemoji
333+
typesapi
331334
uidmap
332335
ukernel
333336
ukvmif
@@ -415,4 +418,4 @@ Logr
415418
onsi
416419
ESRCH
417420
Prafful
418-
praffq
421+
praffq

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/cavaliergopher/cpio v1.0.1
99
github.com/containerd/containerd v1.7.30
1010
github.com/containerd/containerd/api v1.10.0
11+
github.com/containerd/log v0.1.0
1112
github.com/containerd/ttrpc v1.2.7
1213
github.com/creack/pty v1.1.24
1314
github.com/elastic/go-seccomp-bpf v1.6.0
@@ -18,6 +19,7 @@ require (
1819
github.com/nubificus/hedge_cli v0.0.3
1920
github.com/onsi/ginkgo/v2 v2.28.1
2021
github.com/onsi/gomega v1.39.1
22+
github.com/opencontainers/image-spec v1.1.1
2123
github.com/opencontainers/runc v1.3.4
2224
github.com/opencontainers/runtime-spec v1.2.1
2325
github.com/prometheus-community/pro-bing v0.8.0
@@ -44,7 +46,6 @@ require (
4446
github.com/containerd/errdefs/pkg v0.3.0 // indirect
4547
github.com/containerd/fifo v1.1.0 // indirect
4648
github.com/containerd/go-runc v1.0.0 // indirect
47-
github.com/containerd/log v0.1.0 // indirect
4849
github.com/containerd/platforms v0.2.1 // indirect
4950
github.com/containerd/typeurl/v2 v2.2.3 // indirect
5051
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
@@ -67,7 +68,6 @@ require (
6768
github.com/moby/sys/sequential v0.6.0 // indirect
6869
github.com/moby/sys/user v0.4.0 // indirect
6970
github.com/opencontainers/go-digest v1.0.0 // indirect
70-
github.com/opencontainers/image-spec v1.1.1 // indirect
7171
github.com/pkg/errors v0.9.1 // indirect
7272
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
7373
github.com/stretchr/objx v0.5.3 // indirect
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Copyright (c) 2023-2026, Nubificus LTD
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package containerd
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"io"
22+
"os"
23+
"path/filepath"
24+
"strings"
25+
26+
contentapi "github.com/containerd/containerd/api/services/content/v1"
27+
imagesapi "github.com/containerd/containerd/api/services/images/v1"
28+
typesapi "github.com/containerd/containerd/api/types"
29+
"github.com/containerd/containerd/images"
30+
31+
imagespec "github.com/opencontainers/image-spec/specs-go/v1"
32+
runtimespec "github.com/opencontainers/runtime-spec/specs-go"
33+
)
34+
35+
const uruncPrefix = "com.urunc.unikernel."
36+
37+
type annotationFetcher struct {
38+
containerLabels map[string]string
39+
contentClient contentapi.ContentClient
40+
namespace string
41+
target *typesapi.Descriptor
42+
}
43+
44+
func newAnnotationFetcher(ctx context.Context, session *Session) (*annotationFetcher, error) {
45+
container := session.GetContainer()
46+
if container == nil {
47+
return nil, fmt.Errorf("container metadata is not loaded")
48+
}
49+
50+
fetcher := &annotationFetcher{
51+
containerLabels: container.Labels,
52+
contentClient: session.contentClient(),
53+
namespace: session.GetNamespace(),
54+
}
55+
56+
if container.Image == "" {
57+
// TODO: Add Docker fallback. When Docker does not use containerd's image/content
58+
// store, the containerd container metadata may not include an image reference.
59+
// In that case, resolve the image reference through the Docker Engine API and
60+
// fetch manifest annotations from a Docker-compatible path.
61+
return fetcher, nil
62+
}
63+
64+
imageResp, err := session.imagesClient().Get(
65+
withNamespace(ctx, session.GetNamespace()),
66+
&imagesapi.GetImageRequest{Name: container.Image},
67+
)
68+
if err != nil {
69+
return fetcher, nil
70+
}
71+
72+
fetcher.target = imageResp.GetImage().GetTarget()
73+
return fetcher, nil
74+
}
75+
76+
func InjectUruncAnnotations(ctx context.Context, session *Session, bundlePath string) error {
77+
fetcher, err := newAnnotationFetcher(ctx, session)
78+
if err != nil {
79+
return fmt.Errorf("create annotation fetcher: %w", err)
80+
}
81+
annotations, err := fetcher.fetchUruncAnnotations(ctx)
82+
if err != nil {
83+
return fmt.Errorf("fetch urunc annotations: %w", err)
84+
}
85+
if len(annotations) == 0 {
86+
return nil
87+
}
88+
89+
return patchConfigJSON(bundlePath, annotations)
90+
}
91+
92+
func (f *annotationFetcher) fetchUruncAnnotations(ctx context.Context) (map[string]string, error) {
93+
filtered := make(map[string]string)
94+
95+
// Collect urunc annotations from container labels.
96+
for k, v := range f.containerLabels {
97+
if strings.HasPrefix(k, uruncPrefix) {
98+
filtered[k] = v
99+
}
100+
}
101+
102+
// Collect urunc annotations from manifest
103+
if f.target == nil || !images.IsManifestType(f.target.MediaType) {
104+
// If the image target is missing or does not point to a manifest,
105+
// keep the labels collected so far.
106+
return filtered, nil
107+
}
108+
109+
manifestRaw, err := readBlob(ctx, f.namespace, f.contentClient, f.target.Digest, f.target.Size)
110+
if err != nil {
111+
return nil, fmt.Errorf("read manifest blob: %w", err)
112+
}
113+
114+
var manifest imagespec.Manifest
115+
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
116+
return nil, fmt.Errorf("unmarshal manifest: %w", err)
117+
}
118+
119+
// Manifest annotations override config labels on duplicate keys.
120+
for k, v := range manifest.Annotations {
121+
if strings.HasPrefix(k, uruncPrefix) {
122+
filtered[k] = v
123+
}
124+
}
125+
126+
return filtered, nil
127+
}
128+
129+
// readBlob reads a blob with the given digest from containerd's content store
130+
// and returns it as a byte slice.
131+
func readBlob(ctx context.Context, namespace string, contentClient contentapi.ContentClient, digest string, size int64) ([]byte, error) {
132+
stream, err := contentClient.Read(withNamespace(ctx, namespace), &contentapi.ReadContentRequest{
133+
Digest: digest,
134+
Size: size,
135+
})
136+
if err != nil {
137+
return nil, containerdErr(err)
138+
}
139+
140+
var raw []byte
141+
for {
142+
resp, err := stream.Recv()
143+
if err == io.EOF {
144+
break
145+
}
146+
if err != nil {
147+
return nil, containerdErr(err)
148+
}
149+
raw = append(raw, resp.Data...)
150+
}
151+
152+
return raw, nil
153+
}
154+
155+
// patchConfigJSON injects missing annotations into the OCI runtime spec
156+
// stored in the bundle's config.json.
157+
//
158+
// Existing annotations in config.json are preserved. Only annotation keys that
159+
// are not already present in the runtime spec are added.
160+
func patchConfigJSON(bundlePath string, annotations map[string]string) error {
161+
configPath := filepath.Join(bundlePath, "config.json")
162+
163+
fi, err := os.Stat(configPath)
164+
if err != nil {
165+
return fmt.Errorf("stat config.json: %w", err)
166+
}
167+
168+
data, err := os.ReadFile(configPath)
169+
if err != nil {
170+
return fmt.Errorf("read config.json: %w", err)
171+
}
172+
173+
var spec runtimespec.Spec
174+
if err := json.Unmarshal(data, &spec); err != nil {
175+
return fmt.Errorf("unmarshal spec: %w", err)
176+
}
177+
178+
if spec.Annotations == nil {
179+
spec.Annotations = make(map[string]string)
180+
}
181+
182+
for k, v := range annotations {
183+
if _, exists := spec.Annotations[k]; exists {
184+
continue
185+
}
186+
spec.Annotations[k] = v
187+
}
188+
189+
patched, err := json.MarshalIndent(spec, "", " ")
190+
if err != nil {
191+
return fmt.Errorf("marshal spec: %w", err)
192+
}
193+
194+
if err := atomicWriteFile(configPath, patched, fi.Mode()); err != nil {
195+
return fmt.Errorf("write config.json atomically: %w", err)
196+
}
197+
return nil
198+
}
199+
200+
func atomicWriteFile(path string, data []byte, mode os.FileMode) error {
201+
tmpDir := filepath.Dir(path)
202+
203+
f, err := os.CreateTemp(tmpDir, "."+filepath.Base(path)+".tmp-*")
204+
if err != nil {
205+
return err
206+
}
207+
208+
tmpName := f.Name()
209+
defer os.Remove(tmpName)
210+
211+
if err := f.Chmod(mode); err != nil {
212+
_ = f.Close()
213+
return err
214+
}
215+
216+
if _, err := f.Write(data); err != nil {
217+
_ = f.Close()
218+
return err
219+
}
220+
221+
if err := f.Sync(); err != nil {
222+
_ = f.Close()
223+
return err
224+
}
225+
226+
if err := f.Close(); err != nil {
227+
return err
228+
}
229+
230+
if err := os.Rename(tmpName, path); err != nil {
231+
return err
232+
}
233+
234+
return nil
235+
}

pkg/containerd-shim/containerd/session.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,10 @@ func (s *Session) containersClient() containersapi.ContainersClient {
150150
return containersapi.NewContainersClient(s.conn)
151151
}
152152

153-
//nolint:unused // Used by follow-up feature-specific access constructors.
154153
func (s *Session) imagesClient() imagesapi.ImagesClient {
155154
return imagesapi.NewImagesClient(s.conn)
156155
}
157156

158-
//nolint:unused // Used by follow-up feature-specific access constructors.
159157
func (s *Session) contentClient() contentapi.ContentClient {
160158
return contentapi.NewContentClient(s.conn)
161159
}

pkg/containerd-shim/task_service.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,35 @@ import (
1818
"context"
1919

2020
taskAPI "github.com/containerd/containerd/api/runtime/task/v2"
21+
"github.com/containerd/log"
2122
"github.com/containerd/ttrpc"
23+
containerdShim "github.com/urunc-dev/urunc/pkg/containerd-shim/containerd"
2224
)
2325

2426
// taskService is urunc's shim-side wrapper around containerd's runc task
25-
// service. It currently forwards calls to the wrapped service while keeping a
26-
// urunc-owned place for task-level feature wiring.
27+
// service. It wires urunc task setup before forwarding calls to the wrapped
28+
// service.
2729
type taskService struct {
2830
taskAPI.TaskService
2931

3032
containerdAddress string
3133
}
3234

3335
func (s *taskService) Create(ctx context.Context, r *taskAPI.CreateTaskRequest) (*taskAPI.CreateTaskResponse, error) {
36+
session, err := containerdShim.OpenSession(ctx, s.containerdAddress, r.ID)
37+
if err != nil {
38+
log.G(ctx).WithError(err).Warn("urunc(shim): failed to open containerd session")
39+
} else {
40+
defer func() {
41+
if err := session.Close(); err != nil {
42+
log.G(ctx).WithError(err).Warn("urunc(shim): failed to close containerd session")
43+
}
44+
}()
45+
if err := containerdShim.InjectUruncAnnotations(ctx, session, r.Bundle); err != nil {
46+
log.G(ctx).WithError(err).Warn("urunc(shim): failed to inject annotations to spec")
47+
}
48+
}
49+
3450
return s.TaskService.Create(ctx, r)
3551
}
3652

pkg/unikontainers/config.go

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,23 +76,15 @@ func (c *UnikernelConfig) validate() error {
7676

7777
// GetUnikernelConfig tries to get the Unikernel config from the bundle annotations.
7878
// If that fails, it gets the Unikernel config from the urunc.json file inside the rootfs.
79-
// FIXME: custom annotations are unreachable, we need to investigate why to skip adding the urunc.json file
80-
// For more details, see: https://github.com/urunc-dev/urunc/issues/12
81-
// GetUnikernelConfig tries to get a valid Unikernel config from the bundle annotations.
82-
// If the annotations do not provide a valid config, it falls back to the urunc.json file.
8379
func GetUnikernelConfig(bundleDir string, spec *specs.Spec) (*UnikernelConfig, error) {
84-
8580
conf := getConfigFromSpec(spec)
86-
87-
err := conf.validate()
88-
if err == nil {
89-
90-
if err := conf.decode(); err != nil {
91-
return nil, err
92-
}
81+
if err := conf.validate(); err == nil {
82+
// TODO: in case of urunc executed without shim, the annotations would remain endcoded
9383
return conf, nil
9484
}
9585

86+
// Failed to fetch urunc annotations from spec, fallback to urunc.json
87+
uniklog.Info("failed to fetch urunc annotations from spec, fallback to urunc.json")
9688
rootFSDir := spec.Root.Path
9789
var jsonFilePath string
9890
if filepath.IsAbs(rootFSDir) {

0 commit comments

Comments
 (0)