summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-16 17:05:05 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-16 17:05:05 +0000
commit28c641cb32dad63d7ea360a3dc52fc31fb267693 (patch)
treee2fc866d190e1889023635f5d5b940b15d03becb
parentInitial commit. (diff)
downloadgolang-github-container-orchestrated-devices-container-device-interface-28c641cb32dad63d7ea360a3dc52fc31fb267693.tar.xz
golang-github-container-orchestrated-devices-container-device-interface-28c641cb32dad63d7ea360a3dc52fc31fb267693.zip
Adding upstream version 0.6.2.upstream/0.6.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r--.codespellrc4
-rw-r--r--.github/dependabot.yml16
-rw-r--r--.github/workflows/codespell.yml19
-rw-r--r--.github/workflows/sanity.yml72
-rw-r--r--.gitignore3
-rw-r--r--CONTRIBUTING.md68
-rw-r--r--LICENSE201
-rw-r--r--Makefile130
-rw-r--r--README.md192
-rw-r--r--SECURITY.md2
-rw-r--r--SPEC.md233
-rw-r--r--cmd/cdi/cmd/cdi-api.go350
-rw-r--r--cmd/cdi/cmd/classes.go35
-rw-r--r--cmd/cdi/cmd/devices.go50
-rw-r--r--cmd/cdi/cmd/dirs.go41
-rw-r--r--cmd/cdi/cmd/format.go66
-rw-r--r--cmd/cdi/cmd/inject.go94
-rw-r--r--cmd/cdi/cmd/monitor.go167
-rw-r--r--cmd/cdi/cmd/resolve.go92
-rw-r--r--cmd/cdi/cmd/root.go78
-rw-r--r--cmd/cdi/cmd/specs.go57
-rw-r--r--cmd/cdi/cmd/validate.go57
-rw-r--r--cmd/cdi/cmd/vendors.go35
-rw-r--r--cmd/cdi/go.mod30
-rw-r--r--cmd/cdi/go.sum73
-rw-r--r--cmd/cdi/main.go23
-rw-r--r--cmd/validate/go.mod17
-rw-r--r--cmd/validate/go.sum27
-rw-r--r--cmd/validate/validate.go102
-rw-r--r--code-of-conduct.md3
-rw-r--r--go.mod27
-rw-r--r--go.sum66
-rw-r--r--internal/multierror/multierror.go82
-rw-r--r--internal/multierror/multierror_test.go38
-rw-r--r--internal/validation/k8s/objectmeta.go57
-rw-r--r--internal/validation/k8s/validation.go217
-rw-r--r--internal/validation/validate.go56
-rw-r--r--pkg/cdi/annotations.go141
-rw-r--r--pkg/cdi/annotations_test.go458
-rw-r--r--pkg/cdi/cache.go581
-rw-r--r--pkg/cdi/cache_test.go1883
-rw-r--r--pkg/cdi/cache_test_unix.go26
-rw-r--r--pkg/cdi/cache_test_windows.go22
-rw-r--r--pkg/cdi/container-edits.go332
-rw-r--r--pkg/cdi/container-edits_test.go624
-rw-r--r--pkg/cdi/container-edits_unix.go88
-rw-r--r--pkg/cdi/container-edits_windows.go27
-rw-r--r--pkg/cdi/device.go88
-rw-r--r--pkg/cdi/device_test.go78
-rw-r--r--pkg/cdi/doc.go276
-rw-r--r--pkg/cdi/qualified-device.go113
-rw-r--r--pkg/cdi/qualified-device_test.go153
-rw-r--r--pkg/cdi/registry.go150
-rw-r--r--pkg/cdi/registry_test.go823
-rw-r--r--pkg/cdi/regressions_test.go183
-rw-r--r--pkg/cdi/spec-dirs.go114
-rw-r--r--pkg/cdi/spec-dirs_test.go249
-rw-r--r--pkg/cdi/spec.go352
-rw-r--r--pkg/cdi/spec_linux.go48
-rw-r--r--pkg/cdi/spec_other.go39
-rw-r--r--pkg/cdi/spec_test.go682
-rw-r--r--pkg/cdi/validate/schema.go58
-rw-r--r--pkg/cdi/version.go188
-rw-r--r--pkg/parser/parser.go212
-rw-r--r--pkg/parser/parser_test.go153
-rw-r--r--schema/Makefile27
-rw-r--r--schema/defs.json144
-rw-r--r--schema/schema.go351
-rw-r--r--schema/schema.json45
-rw-r--r--schema/schema_test.go377
-rw-r--r--schema/testdata/bad/empty.json0
-rw-r--r--schema/testdata/bad/explicit-empty.json1
-rw-r--r--schema/testdata/good/minimal.json12
-rw-r--r--schema/testdata/good/spec-example.json32
-rw-r--r--specs-go/config.go62
-rw-r--r--specs-go/go.mod5
-rw-r--r--specs-go/go.sum2
-rw-r--r--specs-go/oci.go38
78 files changed, 12117 insertions, 0 deletions
diff --git a/.codespellrc b/.codespellrc
new file mode 100644
index 0000000..05a029b
--- /dev/null
+++ b/.codespellrc
@@ -0,0 +1,4 @@
+[codespell]
+skip = .git,*.pdf,*.svg,*.sum,*.mod
+#
+# ignore-words-list =
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..d4d0856
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,16 @@
+version: 2
+updates:
+- package-ecosystem: gomod
+ directory: "/"
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 10
+ allow:
+ - dependency-name: "github.com/opencontainers/runtime-spec"
+ dependency-type: direct
+
+- package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 10
diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml
new file mode 100644
index 0000000..3bda702
--- /dev/null
+++ b/.github/workflows/codespell.yml
@@ -0,0 +1,19 @@
+---
+name: Codespell
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ codespell:
+ name: Check for spelling errors
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Codespell
+ uses: codespell-project/actions-codespell@v2
diff --git a/.github/workflows/sanity.yml b/.github/workflows/sanity.yml
new file mode 100644
index 0000000..3a67092
--- /dev/null
+++ b/.github/workflows/sanity.yml
@@ -0,0 +1,72 @@
+on: [push, pull_request]
+name: Sanity
+
+env:
+ GO_VERSION: '1.19.x'
+
+jobs:
+ build:
+ name: "Run go sanity tools"
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - goos: linux
+ goarch: amd64
+ - goos: windows
+ goarch: amd64
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v4
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ - name: Install golint
+ run: go install golang.org/x/lint/golint@latest
+ - name: Lint
+ run: make lint
+ - name: Fmt
+ run: |
+ make fmt && git diff --exit-code
+ - name: Vet
+ run: make vet
+ - name: Verify go modules are properly up-to-date
+ run: |
+ make mod-tidy && git diff --exit-code
+
+ test:
+ name: "Run tests"
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v4
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ - name: Test
+ run: make test
+
+
+ # Make sure binaries compile on multiple platforms.
+ crossbuild:
+ name: "Build / Crossbuild Binaries"
+ runs-on: ubuntu-20.04
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - goos: linux
+ goarch: amd64
+ - goos: windows
+ goarch: amd64
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v4
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ - name: Build
+ env:
+ GOOS: ${{matrix.goos}}
+ GOARCH: ${{matrix.goarch}}
+ run: |
+ env | grep ^GO
+ make
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..891783c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.swp
+schema/validate
+/bin
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..8a47c11
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,68 @@
+# Contribute to CDI
+
+Want to contribute to the Container Device Interface Project? Awesome!
+We welcome contributions. In addition to signing your work as described in
+the section below we ask that the following guidelines be considered when
+opening, reviewing, and merging pull requests.
+
+* No one should merge their own code or code from their own organization
+* Reviewers should included people from an organization that is not your own
+* If no changes have been requested by other reviewers the (last) reviewer
+merges the pull request on approval
+* The CNCF [#tag-runtime](https://cloud-native.slack.com/archives/CPBE97SMU)
+Slack channel should be used for discussion
+
+## Sign your work
+
+The sign-off is a simple line at the end of the explanation for the patch. Your
+signature certifies that you wrote the patch or otherwise have the right to pass
+it on as an open-source patch. The rules are pretty simple: if you can certify
+the below (from [developercertificate.org](http://developercertificate.org/)):
+
+```
+Developer Certificate of Origin
+Version 1.1
+
+Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
+1 Letterman Drive
+Suite D4700
+San Francisco, CA, 94129
+
+Everyone is permitted to copy and distribute verbatim copies of this
+license document, but changing it is not allowed.
+
+Developer's Certificate of Origin 1.1
+
+By making a contribution to this project, I certify that:
+
+(a) The contribution was created in whole or in part by me and I
+ have the right to submit it under the open source license
+ indicated in the file; or
+
+(b) The contribution is based upon previous work that, to the best
+ of my knowledge, is covered under an appropriate open source
+ license and I have the right under that license to submit that
+ work with modifications, whether created in whole or in part
+ by me, under the same open source license (unless I am
+ permitted to submit under a different license), as indicated
+ in the file; or
+
+(c) The contribution was provided directly to me by some other
+ person who certified (a), (b) or (c) and I have not modified
+ it.
+
+(d) I understand and agree that this project and the contribution
+ are public and that a record of the contribution (including all
+ personal information I submit with it, including my sign-off) is
+ maintained indefinitely and may be redistributed consistent with
+ this project or the open source license(s) involved.
+```
+
+Then you just add a line to every git commit message:
+
+ Signed-off-by: Joe Smith <joe.smith@email.com>
+
+Use your real name (sorry, no pseudonyms or anonymous contributions.)
+
+If you set your `user.name` and `user.email` git configs, you can sign your
+commit automatically with `git commit -s`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..27d4061
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,130 @@
+# Copyright © The CDI Authors
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+GO_CMD := go
+GO_BUILD := $(GO_CMD) build
+GO_TEST := $(GO_CMD) test -race -v -cover
+
+GO_LINT := golint -set_exit_status
+GO_FMT := gofmt
+GO_VET := $(GO_CMD) vet
+
+CDI_PKG := $(shell grep ^module go.mod | sed 's/^module *//g')
+
+CMDS := $(patsubst ./cmd/%/,%,$(sort $(dir $(wildcard ./cmd/*/))))
+BINARIES := $(patsubst %,bin/%,$(CMDS))
+
+ifneq ($(V),1)
+ Q := @
+endif
+
+
+#
+# top-level targets
+#
+
+all: build
+
+build: $(BINARIES)
+
+clean: clean-binaries clean-schema
+
+test: test-gopkgs test-schema
+
+#
+# validation targets
+#
+
+pre-pr-checks pr-checks: test fmt lint vet
+
+fmt format:
+ $(Q)$(GO_FMT) -s -d -w -e .
+
+lint:
+ $(Q)$(GO_LINT) -set_exit_status ./...
+vet:
+ $(Q)$(GO_VET) ./...
+
+#
+# build targets
+#
+
+$(BINARIES): bin/%:
+ $(Q)echo "Building $@..."
+ $(Q)(cd cmd/$(*) && $(GO_BUILD) -o $(abspath $@) .)
+
+#
+# go module tidy and verify targets
+#
+.PHONY: mod-tidy
+.PHONY: mod-verify
+
+mod-tidy:
+ $(Q)for mod in $$(find . -name go.mod); do \
+ echo "Tidying $$mod..."; ( \
+ cd $$(dirname $$mod) && go mod tidy \
+ ) || exit 1; \
+ done
+
+mod-verify:
+ $(Q)for mod in $$(find . -name go.mod); do \
+ echo "Verifying $$mod..."; ( \
+ cd $$(dirname $$mod) && go mod verify | sed 's/^/ /g' \
+ ) || exit 1; \
+ done
+
+#
+# cleanup targets
+#
+
+# clean up binaries
+clean-binaries:
+ $(Q) rm -f $(BINARIES)
+
+# clean up schema validator
+clean-schema:
+ $(Q)rm -f schema/validate
+
+#
+# test targets
+#
+
+# tests for go packages
+test-gopkgs:
+ $(Q)$(GO_TEST) ./...
+
+# tests for CDI Spec JSON schema
+test-schema: bin/validate
+ $(Q)echo "Building in schema..."; \
+ $(MAKE) -C schema test
+
+
+#
+# dependencies
+#
+
+bin/validate: $(wildcard schema/*.json) $(wildcard cmd/validate/*.go cmd/validate/cmd/*.go) $(shell \
+ for dir in \
+ $$(cd ./cmd/validate; $(GO_CMD) list -f '{{ join .Deps "\n"}}' ./... | \
+ grep $(CDI_PKG)/pkg/ | \
+ sed 's:$(CDI_PKG):.:g'); do \
+ find $$dir -name \*.go; \
+ done | sort | uniq)
+
+# quasi-automatic dependency for bin/cdi
+bin/cdi: $(wildcard cmd/cdi/*.go cmd/cdi/cmd/*.go) $(shell \
+ for dir in \
+ $$(cd ./cmd/cdi; $(GO_CMD) list -f '{{ join .Deps "\n"}}' ./... | \
+ grep $(CDI_PKG)/pkg/ | \
+ sed 's:$(CDI_PKG):.:g'); do \
+ find $$dir -name \*.go; \
+ done | sort | uniq)
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0218c8b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,192 @@
+# CDI - The Container Device Interface
+
+**NOTE:** The API for injecting CDI devices that existed at `container-device-interface/pkg` has been removed. Users of this API should migrate to the one at `container-device-interface/pkg/cdi` as this is actively maintained.
+
+## What is CDI?
+
+CDI (Container Device Interface), is a [specification](SPEC.md), for container-runtimes, to support third-party devices.
+
+It introduces an abstract notion of a device as a resource. Such devices are
+uniquely specified by a fully-qualified name that is constructed from a vendor
+ID, a device class, and a name that is unique per vendor ID-device class pair.
+
+```
+vendor.com/class=unique_name
+```
+
+The combination of vendor ID and device class (`vendor.com/class` in the above
+example) is referred to as the device kind.
+
+CDI concerns itself only with enabling containers to be device aware. Areas like
+resource management are explicitly left out of CDI (and are expected to be
+handled by the orchestrator). Because of this focus, the CDI specification is
+simple to implement and allows great flexibility for runtimes and orchestrators.
+
+Note: The CDI model is based on the Container Networking Interface (CNI) model
+and [specification](https://github.com/containernetworking/cni/blob/main/SPEC.md).
+
+## Why is CDI needed?
+
+On Linux, enabling a container to be device aware used to be as simple as
+exposing a device node in that container. However, as devices and software grows
+more complex, vendors want to perform more operations, such as:
+
+- Exposing a device to a container can require exposing more than one device
+ node, mounting files from the runtime namespace, or hiding procfs entries.
+- Performing compatibility checks between the container and the device (e.g: Can
+ this container run on this device?).
+- Performing runtime-specific operations (e.g: VM vs Linux container-based
+ runtimes).
+- Performing device-specific operations (e.g: scrubbing the memory of a GPU or
+ reconfiguring an FPGA).
+
+In the absence of a standard for third-party devices, vendors often have to
+write and maintain multiple plugins for different runtimes or even directly
+contribute vendor-specific code in the runtime. Additionally, runtimes don't
+uniformly expose a plugin system (or even expose a plugin system at all) leading
+to duplication of the functionality in higher-level abstractions (such as
+[Kubernetes device plugins](https://kubernetes.io/docs/concepts/extend-kubernetes/compute-storage-net/device-plugins/)).
+
+## How does CDI work?
+
+For CDI to work the following needs to be done:
+
+- CDI file containing updates for the OCI spec in JSON format should be present
+ in the CDI spec directory. Default directories are `/etc/cdi` and
+ `/var/run/cdi`
+- Fully qualified device name should be passed to the runtime either using
+ command line parameters for podman or using container annotations for CRI-O
+ and containerd
+- Container runtime should be able to find the CDI file by the device name and
+ update the container config using CDI file content.
+
+## How to configure CDI?
+
+### CRI-O configuration
+
+In CRI-O CDI support is enabled by default. It is configured with the default
+`/etc/cdi, /var/run/cdi` CDI directory locations. Therefore, you can start using
+CDI simply by dropping CDI configuration files in either of those directories,
+static configuration into `/etc/cdi` and dynamically updated one into
+`/var/run/cdi`. If you are unsure of the configured directories you can run this
+command to find them out:
+
+```bash
+$ crio config |& grep -B1 -A5 cdi_spec_dirs
+```
+
+### containerd configuration
+
+To enable and configure CDI support in the [containerd
+runtime](https://github.com/containerd/containerd) 2 configuration options
+`enable_cdi` and `cdi_spec_dirs` should be set in the
+`plugins."io.containerd.grpc.v1.cri` section of the containerd configuration
+file (`/etc/containerd/config.toml` by default):
+
+```
+[plugins."io.containerd.grpc.v1.cri"]
+ enable_cdi = true
+ cdi_spec_dirs = ["/etc/cdi", "/var/run/cdi"]
+```
+
+Remember to restart containerd for any configuration changes to take effect.
+### Podman configuration
+
+[podman](https://github.com/containers/podman) does not require any specific
+configuration to enable CDI support and processes specified `--device` flags
+directly. If fully-qualified device selectors (e.g.
+`vendor.com/device=myDevice`) are included the CDI specifications at the default
+location (`/etc/cdi` and `/var/run/cdi`) are checked for matching devices.
+
+*Note:* Although initial support was added in
+[`v3.2.0`](https://github.com/containers/podman/releases/tag/v3.2.0) this was
+updated for the tagged `v0.3.0` CDI spec in
+[`v4.1.0-rc.1`](https://github.com/containers/podman/releases/tag/v4.1.0-rc1)
+with [commit
+a234e4e](https://github.com/containers/podman/commit/a234e4e19662e172472877ce69523f4afea5c12e).
+
+## Examples
+### Full-blown CDI specification
+
+```bash
+$ mkdir /etc/cdi
+$ cat > /etc/cdi/vendor.json <<EOF
+{
+ "cdiVersion": "0.6.0",
+ "kind": "vendor.com/device",
+ "devices": [
+ {
+ "name": "myDevice",
+ "containerEdits": {
+ "deviceNodes": [
+ {"hostPath": "/vendor/dev/card1", "path": "/dev/card1", "type": "c", "major": 25, "minor": 25, "fileMode": 384, "permissions": "rw", "uid": 1000, "gid": 1000},
+ {"path": "/dev/card-render1", "type": "c", "major": 25, "minor": 25, "fileMode": 384, "permissions": "rwm", "uid": 1000, "gid": 1000}
+ ]
+ }
+ }
+ ],
+ "containerEdits": {
+ "env": [
+ "FOO=VALID_SPEC",
+ "BAR=BARVALUE1"
+ ],
+ "deviceNodes": [
+ {"path": "/dev/vendorctl", "type": "b", "major": 25, "minor": 25, "fileMode": 384, "permissions": "rw", "uid": 1000, "gid": 1000}
+ ],
+ "mounts": [
+ {"hostPath": "/bin/vendorBin", "containerPath": "/bin/vendorBin"},
+ {"hostPath": "/usr/lib/libVendor.so.0", "containerPath": "/usr/lib/libVendor.so.0"},
+ {"hostPath": "tmpfs", "containerPath": "/tmp/data", "type": "tmpfs", "options": ["nosuid","strictatime","mode=755","size=65536k"]}
+ ],
+ "hooks": [
+ {"createContainer": {"path": "/bin/vendor-hook"} },
+ {"startContainer": {"path": "/usr/bin/ldconfig"} }
+ ]
+ }
+}
+EOF
+```
+
+Assuming this specification has been generated and is available in either
+`/etc/cdi` or `/var/run/cdi` (or wherever a CDI-enabled consumer is configured
+to read CDI specifications from), the devices can be accessed through their
+fully-qualified device names.
+
+For example, in the case of `podman` the CLI for accessing the device would be:
+```
+$ podman run --device vendor.com/device=myDevice ...
+```
+
+### Using Annotations per device to add meta-information
+
+```bash
+$ mkdir /etc/cdi
+$ cat > /etc/cdi/vendor-annotations.json <<EOF
+{
+ "cdiVersion": "0.6.0",
+ "kind": "vendor.com/device",
+ "devices": [
+ {
+ "name": "myDevice",
+ "annotations": {
+ "whatever": "false"
+ "whenever": "true"
+ }
+ "containerEdits": {
+ "deviceNodes": [
+ {"path": "/dev/vfio/71"}
+ ]
+ }
+ }
+ ]
+}
+EOF
+```
+
+
+## Issues and Contributing
+
+[Check out the Contributing document!](CONTRIBUTING.md)
+
+* Please let us know by [filing a new issue](https://github.com/cncf-tags/container-device-interface/issues/new)
+* You can contribute by opening a [pull request](https://help.github.com/articles/using-pull-requests/)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..a4fbff2
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,2 @@
+# Security Policy
+
diff --git a/SPEC.md b/SPEC.md
new file mode 100644
index 0000000..e3c05d5
--- /dev/null
+++ b/SPEC.md
@@ -0,0 +1,233 @@
+# Container Device Interface Specification
+
+- [Version](#version)
+- [Overview](#overview)
+- [General considerations](#general-considerations)
+- [CDI JSON Specification](#cdi-json-specification)
+- [Error Handling](#error-handling)
+
+## Version
+
+This is CDI **spec** version **0.6.0**.
+
+### Update policy
+
+Any modifications to the **spec** will result in at least a minor version bump. When releasing changes
+that only affect the API also implemented in this repository, the patch version will be bumped.
+
+*Note*: The **spec** is still under active development and there exists the possibility of breaking changes being
+introduced with new versions.
+### Released versions
+
+Released versions of the spec are available as Git tags.
+
+| Tag | Spec Permalink | Change |
+| -----| -----------------| -------|
+| v0.3.0 | | Initial tagged release of Spec |
+| v0.4.0 | | Added `type` field to Mount specification |
+| v0.5.0 | | Add `HostPath` to `DeviceNodes` |
+| v0.6.0 | | Add `Annotations` field to `Spec` and `Device` specifications |
+| | | Allow dots (`.`) in name segment of `Kind` field |
+
+*Note*: The initial release of a **spec** with version `v0.x.0` will be tagged as
+`v0.x.0` with subsequent changes to the API applicable to this version tagged as `v0.x.y`.
+## Overview
+
+The _Container Device Interface_, or _CDI_ describes a mechanism for container runtimes to create containers which are able to interact with third party devices.
+
+For third party devices, it is often the case that interacting with these devices require container runtimes to expose more than a device node. For example a third party device might require you to have kernel modules loaded, host libraries mounted or specific procfs path exposed/masked.
+
+The _Container Device Interface_ describes a mechanism which allows third party vendors to perform these operations such that it doesn't require changing the container runtime.
+
+The mechanism used is a JSON file (similar the [Container Network Interface (CNI)][cni]) which allows vendors to describe the operations the container runtime should perform on the container's [OCI specification][oci].
+
+The Container Device Interface enables the following two flows:
+
+A. Device Installation
+ 1. A user installs a third party device driver (and third party device) on a machine.
+ 2. The device driver installation software writes a JSON file at a well known path (`/etc/cdi/vendor.json`).
+
+B. Container Runtime
+ 1. A user runs a container with the argument `--device` followed by a device name.
+ 2. The container runtime reads the JSON file.
+ 3. The container runtime validates that the device is described in the JSON file.
+ 4. The container runtime pulls the container image.
+ 5. The container runtime generates an OCI specification.
+ 6. The container runtime transforms the OCI specification according to the instructions in the JSON file
+
+
+[cni]: https://github.com/containernetworking/cni
+[oci]: https://github.com/opencontainers/runtime-spec
+
+## General considerations
+
+- The device configuration is in JSON format and can easily be stored in a file.
+- The device configuration includes mandatory fields such as "name" and "version".
+- Fields in CDI structures are required unless specifically marked optional.
+
+For the purposes of this proposal, we define the following terms:
+- _container_ is an instance of code running in an isolated execution environment specified by the OCI image and runtime specifications.
+- _device_ which refers to an actual hardware device.
+- _runtime engine_ is a component that creates a container from an OCI Runtime Specification and a rootfs directory
+- _container runtime_ which refers to the higher level component users tend to interact with for managing containers. It may also include lower level components that implement management of containers and pods (sets of containers). e.g: docker, podman, ...
+- _container runtime interface integration_ which refers to a server that implements the Container Runtime Interface (CRI) services, e.g: containerd+cri, cri-o, ...
+
+The keywords "must", "must not", "required", "shall", "shall not", "should", "should not", "recommended", "may" and "optional" are used as specified in [RFC 2119][rfc-2119].
+
+[rfc-2119]: https://www.ietf.org/rfc/rfc2119.txt
+
+## CDI JSON Specification
+
+### JSON Definition
+
+```
+{
+ "cdiVersion": "0.6.0",
+ "kind": "<name>",
+
+ // This field contains a set of key-value pairs that may be used to provide
+ // additional information to a consumer on the spec.
+ "annotations": { (optional)
+ "key": "value"
+ },
+
+ "devices": [
+ {
+ "name": "<name>",
+
+ // This field contains a set of key-value pairs that may be used to provide
+ // additional information to a consumer on the specific device.
+ "annotations": { (optional)
+ "key": "value"
+ },
+
+ // Same as the below containerSpec field.
+ // This field should only be applied to the Container's OCI spec
+ // if that specific device is requested.
+ "containerEdits": { ... }
+ }
+ ],
+
+ // This field should be applied to the Container's OCI spec if any of the
+ // devices defined above are requested on the CLI
+ "containerEdits": [
+ {
+ "env": [ (optional)
+ "<envName>=<envValue>"
+ ]
+ "deviceNodes": [ (optional)
+ {
+ "path": "<path>",
+ "hostPath": "<hostPath>" (optional),
+ "type": "<type>" (optional),
+ "major": <int32> (optional),
+ "minor": <int32> (optional),
+ // file mode for the device
+ "fileMode": <int> (optional),
+ // Cgroups permissions of the device, candidates are one or more of
+ // * r - allows container to read from the specified device.
+ // * w - allows container to write to the specified device.
+ // * m - allows container to create device files that do not yet exist.
+ "permissions": "<permissions>" (optional),
+ "uid": <int> (optional),
+ "gid": <int> (optional)
+ }
+ ]
+ "mounts": [ (optional)
+ {
+ "hostPath": "<source>",
+ "containerPath": "<destination>",
+ "type": "<OCI Mount Type>", (optional)
+ "options": "<OCI Mount Options>" (optional)
+ }
+ ],
+ "hooks": [ (optional)
+ {
+ "hookName": "<hookName>",
+ "path": "<path>",
+ "args": ["<arg>", "<arg>"], (optional)
+ "env": [ "<envName>=<envValue>"], (optional)
+ "timeout": <int> (optional)
+ }
+ ]
+ }
+ ]
+}
+```
+
+#### Specification version
+
+* `cdiVersion` (string, REQUIRED) MUST be in [Semantic Version 2.0](https://semver.org) and specifies the version of the CDI specification used by the vendor.
+
+#### Kind
+
+* `kind` (string, REQUIRED) field specifies a label which uniquely identifies the device vendor.
+ It can be used to disambiguate the vendor that matches a device, e.g: `docker/podman run --device vendor.com/device=foo ...`.
+ * The `kind` label has two segments: a prefix and a name, separated by a slash (/).
+ * The name segment is required and must be 63 characters or less, beginning and ending with an alphanumeric character ([a-z0-9A-Z]) with dashes (-), underscores (\_), dots (.), and alphanumerics between.
+ * The prefix must be a DNS subdomain: a series of DNS labels separated by dots (.), not longer than 253 characters in total, followed by a slash (/).
+ * Examples (not an exhaustive list):
+ * Valid: `vendor.com/foo`, `foo.bar.baz/foo-bar123.B_az`.
+ * Invalid: `foo`, `vendor.com/foo/`, `vendor.com/foo/bar`.
+
+#### CDI Devices
+
+The `devices` field describes the set of hardware devices that can be requested by the container runtime user.
+Note: For a CDI file to be valid, at least one entry must be specified in this array.
+
+ * `devices` (array of objects, REQUIRED) list of devices provided by the vendor.
+ * `name` (string, REQUIRED), name of the device, can be used to refer to it when requesting a device.
+ * Beginning and ending with an alphanumeric character ([a-z0-9A-Z]) with dashes (-), underscores (\_), dots (.), and alphanumerics between.
+ * e.g: `docker/podman run --device foo ...`
+ * Entries in the array MUST use the same schema as the entry for the `name` field
+ * `containerEdits` (object, OPTIONAL) this field is described in the next section.
+ * This field should only be merged in the OCI spec if the device has been requested by the container runtime user.
+
+
+#### OCI Edits
+
+The `containerEdits` field describes edits to be made to the OCI specification. Currently, the following kinds of edits can be made to the OCI specification: `env`, `devices`, `mounts` and `hooks`.
+
+The `containerEdits` field is referenced in two places in the specification:
+ * At the device level, where the edits MUST only be made if the matching device is requested by the container runtime user.
+ * At the container level, where the edits MUST be made if any of the device defined in the `devices` field are requested.
+
+
+The `containerEdits` field has the following definition:
+ * `env` (array of strings in the format of "VARNAME=VARVALUE", OPTIONAL) describes the environment variables that should be set. These values are appended to the container environment array.
+ * `deviceNodes` (array of objects, OPTIONAL) describes the device nodes that should be mounted:
+ * `path` (string, REQUIRED) path of the device within the container.
+ * `hostPath` (string, OPTIONAL) path of the device node on the host. If not specified the value for `path` is used.
+ * `type` (string, OPTIONAL) Device type: block, char, etc.
+ * `major` (int64, OPTIONAL) Device major number.
+ * `minor` (int64, OPTIONAL) Device minor number.
+ * `fileMode` (int64, OPTIONAL) file mode for the device.
+ * `permissions` (string, OPTIONAL) Cgroups permissions of the device, candidates are one or more of:
+ * r - allows container to read from the specified device.
+ * w - allows container to write to the specified device.
+ * m - allows container to create device files that do not yet exist.
+ * `uid` (uint32, OPTIONAL) id of device owner in the container namespace.
+ * `gid` (uint32, OPTIONAL) id of device group in the container namespace.
+ * `mounts` (array of objects, OPTIONAL) describes the mounts that should be mounted:
+ * `hostPath` (string, REQUIRED) path of the device on the host.
+ * `containerPath` (string, REQUIRED) path of the device within the container.
+ * `type` (string, OPTIONAL) the type of the filesystem to be mounted. For bind mounts (when options include either bind or rbind), the type is a dummy, often "none" (not listed in /proc/filesystems).
+ * `options` (array of strings, OPTIONAL) Mount options of the filesystem to be used.
+ * `hooks` (array of objects, OPTIONAL) describes the hooks that should be ran:
+ * `hookName` is the name of the hook to invoke, if the runtime is OCI compliant it should be one of {createRuntime, createContainer, startContainer, poststart, poststop}.
+ Runtimes are free to allow custom hooks but it is advised for vendors to create a specific JSON file targeting that runtime
+ * `path` (string, REQUIRED) with similar semantics to IEEE Std 1003.1-2008 execv's path. This specification extends the IEEE standard in that path MUST be absolute.
+ * `args` (array of strings, OPTIONAL) with the same semantics as IEEE Std 1003.1-2008 execv's argv.
+ * `env` (array of strings, OPTIONAL) with the same semantics as IEEE Std 1003.1-2008's environ.
+ * `timeout` (int, OPTIONAL) is the number of seconds before aborting the hook. If set, timeout MUST be greater than zero. If not set container runtime will wait for the hook to return.
+
+## Error Handling
+ * Kind requested is not present in any CDI file.
+ Container runtimes should surface an error when a non-existent kind is requested.
+ * Device (not device node) Requested does not exist.
+ Container runtimes should surface this error when a non existent device is requested.
+ * "Resource" does not exist (e.g: Mount, Hook, ...).
+ Container runtimes should surface this error when a non-existent "resource" is requested (e.g: at "run" time).
+ This is because a resource does not need to exist when the spec is written, but it needs to exist when the container is created.
+ * Hook fails to execute.
+ Container runtimes should surface an error when hooks fails to execute.
diff --git a/cmd/cdi/cmd/cdi-api.go b/cmd/cdi/cmd/cdi-api.go
new file mode 100644
index 0000000..f9bb5ed
--- /dev/null
+++ b/cmd/cdi/cmd/cdi-api.go
@@ -0,0 +1,350 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ gen "github.com/opencontainers/runtime-tools/generate"
+ "tags.cncf.io/container-device-interface/pkg/cdi"
+)
+
+func cdiListVendors() {
+ var (
+ registry = cdi.GetRegistry()
+ vendors = registry.SpecDB().ListVendors()
+ )
+
+ if len(vendors) == 0 {
+ fmt.Printf("No CDI vendors found.\n")
+ return
+ }
+
+ fmt.Printf("CDI vendors found:\n")
+ for idx, vendor := range vendors {
+ fmt.Printf(" %d. %q (%d CDI Spec Files)\n", idx, vendor,
+ len(registry.SpecDB().GetVendorSpecs(vendor)))
+ }
+}
+
+func cdiListClasses() {
+ var (
+ registry = cdi.GetRegistry()
+ vendors = map[string][]string{}
+ )
+
+ for _, class := range registry.SpecDB().ListClasses() {
+ vendors[class] = []string{}
+ for _, vendor := range registry.SpecDB().ListVendors() {
+ for _, spec := range registry.SpecDB().GetVendorSpecs(vendor) {
+ if spec.GetClass() == class {
+ vendors[class] = append(vendors[class], vendor)
+ }
+ }
+ }
+ }
+
+ if len(vendors) == 0 {
+ fmt.Printf("No CDI device classes found.\n")
+ return
+ }
+
+ fmt.Printf("CDI device classes found:\n")
+ for idx, class := range registry.SpecDB().ListClasses() {
+ sort.Strings(vendors[class])
+ fmt.Printf(" %d. %s (%d vendors: %s)\n", idx, class,
+ len(vendors[class]), strings.Join(vendors[class], ", "))
+ }
+}
+
+func cdiListDevices(verbose bool, format string) {
+ var (
+ registry = cdi.GetRegistry()
+ devices = registry.DeviceDB().ListDevices()
+ )
+
+ if len(devices) == 0 {
+ fmt.Printf("No CDI devices found.\n")
+ return
+ }
+
+ fmt.Printf("CDI devices found:\n")
+ for idx, device := range devices {
+ cdiPrintDevice(idx, registry.DeviceDB().GetDevice(device), verbose, format, 2)
+ }
+}
+
+func cdiPrintDevice(idx int, dev *cdi.Device, verbose bool, format string, level int) {
+ if !verbose {
+ if idx >= 0 {
+ fmt.Printf("%s%d. %s\n", indent(level), idx, dev.GetQualifiedName())
+ return
+ }
+ fmt.Printf("%s%s\n", indent(level), dev.GetQualifiedName())
+ return
+ }
+
+ var (
+ spec = dev.GetSpec()
+ )
+
+ format = chooseFormat(format, spec.GetPath())
+
+ fmt.Printf(" %s (%s)\n", dev.GetQualifiedName(), spec.GetPath())
+ fmt.Printf("%s", marshalObject(level+2, dev.Device, format))
+ edits := spec.ContainerEdits
+ if len(edits.Env)+len(edits.DeviceNodes)+len(edits.Hooks)+len(edits.Mounts) > 0 {
+ fmt.Printf("%s global Spec containerEdits:\n", indent(level+2))
+ fmt.Printf("%s", marshalObject(level+4, spec.ContainerEdits, format))
+ }
+}
+
+func cdiShowSpecDirs() {
+ var (
+ registry = cdi.GetRegistry()
+ specDirs = registry.GetSpecDirectories()
+ cdiErrors = registry.GetErrors()
+ )
+ fmt.Printf("CDI Spec directories in use:\n")
+ for prio, dir := range specDirs {
+ fmt.Printf(" %s (priority %d)\n", dir, prio)
+ for path, specErrors := range cdiErrors {
+ if filepath.Dir(path) != dir {
+ continue
+ }
+ for _, err := range specErrors {
+ fmt.Printf(" - has error %v\n", err)
+ }
+ }
+ }
+}
+
+func cdiInjectDevices(format string, ociSpec *oci.Spec, patterns []string) error {
+ var (
+ registry = cdi.GetRegistry()
+ matches = map[string]struct{}{}
+ devices = []string{}
+ )
+
+ for _, device := range registry.DeviceDB().ListDevices() {
+ for _, glob := range patterns {
+ match, err := filepath.Match(glob, device)
+ if err != nil {
+ return fmt.Errorf("failed to match pattern %q against %q: %w",
+ glob, device, err)
+ }
+ if match {
+ matches[device] = struct{}{}
+ }
+ }
+ }
+ for device := range matches {
+ devices = append(devices, device)
+ }
+ sort.Strings(devices)
+
+ unresolved, err := registry.InjectDevices(ociSpec, devices...)
+
+ if len(unresolved) > 0 {
+ fmt.Printf("Unresolved CDI devices:\n")
+ for idx, device := range unresolved {
+ fmt.Printf(" %d. %s\n", idx, device)
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("OCI device injection failed: %w", err)
+ }
+
+ fmt.Printf("Updated OCI Spec:\n")
+ fmt.Printf("%s", marshalObject(2, ociSpec, format))
+
+ return nil
+}
+
+func cdiResolveDevices(ociSpecFiles ...string) error {
+ var (
+ cache *cdi.Cache
+ ociSpec *oci.Spec
+ devices []string
+ unresolved []string
+ err error
+ )
+
+ if cache, err = cdi.NewCache(); err != nil {
+ return fmt.Errorf("failed to create CDI cache instance: %w", err)
+ }
+
+ for _, ociSpecFile := range ociSpecFiles {
+ ociSpec, err = readOCISpec(ociSpecFile)
+ if err != nil {
+ return err
+ }
+
+ devices = collectCDIDevicesFromOCISpec(ociSpec)
+
+ unresolved, err = cache.InjectDevices(ociSpec, devices...)
+ if len(unresolved) > 0 {
+ fmt.Printf("Unresolved CDI devices:\n")
+ for idx, device := range unresolved {
+ fmt.Printf(" %d. %s\n", idx, device)
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("failed to resolve devices for OCI Spec %q: %w", ociSpecFile, err)
+ }
+
+ format := chooseFormat(injectCfg.output, ociSpecFile)
+ fmt.Printf("%s", marshalObject(2, ociSpec, format))
+ }
+
+ return nil
+}
+
+func collectCDIDevicesFromOCISpec(spec *oci.Spec) []string {
+ var (
+ cdiDevs []string
+ )
+
+ if spec.Linux == nil || len(spec.Linux.Devices) == 0 {
+ return nil
+ }
+
+ devices := spec.Linux.Devices
+ g := gen.NewFromSpec(spec)
+ g.ClearLinuxDevices()
+
+ for _, d := range devices {
+ if !cdi.IsQualifiedName(d.Path) {
+ g.AddDevice(d)
+ continue
+ }
+ cdiDevs = append(cdiDevs, d.Path)
+ }
+
+ return cdiDevs
+}
+
+func cdiListSpecs(verbose bool, format string, vendors ...string) {
+ var (
+ registry = cdi.GetRegistry()
+ )
+
+ format = chooseFormat(format, "format-as.yaml")
+
+ if len(vendors) == 0 {
+ vendors = registry.SpecDB().ListVendors()
+ }
+
+ if len(vendors) == 0 {
+ fmt.Printf("No CDI Specs found.\n")
+ cdiErrors := registry.GetErrors()
+ if len(cdiErrors) > 0 {
+ for path, specErrors := range cdiErrors {
+ fmt.Printf("%s has errors:\n", path)
+ for idx, err := range specErrors {
+ fmt.Printf(" %d. %v\n", idx, err)
+ }
+ }
+ }
+ return
+ }
+
+ fmt.Printf("CDI Specs found:\n")
+ for _, vendor := range registry.SpecDB().ListVendors() {
+ fmt.Printf("Vendor %s:\n", vendor)
+ for _, spec := range registry.SpecDB().GetVendorSpecs(vendor) {
+ cdiPrintSpec(spec, verbose, format, 2)
+ cdiPrintSpecErrors(spec, verbose, 2)
+ }
+ }
+}
+
+func cdiPrintSpec(spec *cdi.Spec, verbose bool, format string, level int) {
+ fmt.Printf("%sSpec File %s\n", indent(level), spec.GetPath())
+
+ if verbose {
+ fmt.Printf("%s", marshalObject(level+2, spec.Spec, format))
+ }
+}
+
+func cdiPrintSpecErrors(spec *cdi.Spec, verbose bool, level int) {
+ var (
+ registry = cdi.GetRegistry()
+ cdiErrors = registry.GetErrors()
+ )
+
+ if len(cdiErrors) > 0 {
+ for path, specErrors := range cdiErrors {
+ if len(specErrors) == 0 {
+ continue
+ }
+ fmt.Printf("%s%s has %d errors:\n", indent(level), path, len(specErrors))
+ for idx, err := range specErrors {
+ fmt.Printf("%s%d. %v\n", indent(level+2), idx, err)
+ }
+ }
+ }
+}
+
+func cdiPrintRegistry(args ...string) {
+ if len(args) == 0 {
+ args = []string{"all"}
+ }
+
+ for _, what := range args {
+ switch what {
+ case "vendors", "vendor":
+ cdiListVendors()
+ case "classes", "class":
+ cdiListClasses()
+ case "specs", "spec":
+ cdiListSpecs(monitorCfg.verbose, monitorCfg.output)
+ case "devices", "device":
+ cdiListDevices(monitorCfg.verbose, monitorCfg.output)
+ case "all":
+ cdiListVendors()
+ cdiListClasses()
+ cdiListSpecs(monitorCfg.verbose, monitorCfg.output)
+ cdiListDevices(monitorCfg.verbose, monitorCfg.output)
+ default:
+ fmt.Printf("Unrecognized CDI aspect/object %q... ignoring it\n", what)
+ }
+ }
+}
+
+func cdiPrintRegistryErrors() {
+ var (
+ registry = cdi.GetRegistry()
+ cdiErrors = registry.GetErrors()
+ )
+
+ if len(cdiErrors) == 0 {
+ return
+ }
+
+ fmt.Printf("CDI Registry has errors:\n")
+ for path, specErrors := range cdiErrors {
+ fmt.Printf("Spec file %s:\n", path)
+ for idx, err := range specErrors {
+ fmt.Printf(" %d: %v\n", idx, strings.TrimRight(err.Error(), "\n"))
+ }
+ }
+}
diff --git a/cmd/cdi/cmd/classes.go b/cmd/cdi/cmd/classes.go
new file mode 100644
index 0000000..ec7bd3b
--- /dev/null
+++ b/cmd/cdi/cmd/classes.go
@@ -0,0 +1,35 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+// classesCmd is our command for listing device classes in the registry.
+var classesCmd = &cobra.Command{
+ Use: "classes",
+ Short: "List CDI device classes",
+ Long: `List CDI device classes found in the registry.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ cdiListClasses()
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(classesCmd)
+}
diff --git a/cmd/cdi/cmd/devices.go b/cmd/cdi/cmd/devices.go
new file mode 100644
index 0000000..f58daea
--- /dev/null
+++ b/cmd/cdi/cmd/devices.go
@@ -0,0 +1,50 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+type devicesFlags struct {
+ verbose bool
+ output string
+}
+
+// devicesCmd is our command for listing devices found in the CDI registry.
+var devicesCmd = &cobra.Command{
+ Aliases: []string{"devs", "dev"},
+ Use: "devices",
+ Short: "List devices in the CDI registry",
+ Long: `
+The 'devices' command lists devices found in the CDI registry.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ cdiListDevices(devicesCfg.verbose, devicesCfg.output)
+ },
+}
+
+var (
+ devicesCfg devicesFlags
+)
+
+func init() {
+ rootCmd.AddCommand(devicesCmd)
+ devicesCmd.Flags().BoolVarP(&devicesCfg.verbose,
+ "verbose", "v", false, "list CDI Spec details")
+ devicesCmd.Flags().StringVarP(&devicesCfg.output,
+ "output", "o", "", "output format for details (json|yaml)")
+}
diff --git a/cmd/cdi/cmd/dirs.go b/cmd/cdi/cmd/dirs.go
new file mode 100644
index 0000000..13b1ee4
--- /dev/null
+++ b/cmd/cdi/cmd/dirs.go
@@ -0,0 +1,41 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+// dirsCmd is our command for listing CDI Spec directories in use.
+var dirsCmd = &cobra.Command{
+ Use: "dirs",
+ Short: "Show CDI Spec directories in use",
+ Long: `
+Show which directories are used by the registry to discover and
+load CDI Specs. The later an entry is in the list the higher its
+priority. This priority is inherited by Spec files loaded from
+the directory and is used to resolve device conflicts. If there
+are multiple definitions for a CDI device, the Spec file with
+the highest priority takes precedence over the others.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ cdiShowSpecDirs()
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(dirsCmd)
+}
diff --git a/cmd/cdi/cmd/format.go b/cmd/cdi/cmd/format.go
new file mode 100644
index 0000000..3372696
--- /dev/null
+++ b/cmd/cdi/cmd/format.go
@@ -0,0 +1,66 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "sigs.k8s.io/yaml"
+)
+
+func chooseFormat(format string, path string) string {
+ if format == "" {
+ if ext := filepath.Ext(path); ext == ".json" || ext == ".yaml" {
+ format = ext[1:]
+ } else {
+ format = "yaml"
+ }
+ }
+ return format
+}
+
+func marshalObject(level int, obj interface{}, format string) string {
+ var (
+ raw []byte
+ err error
+ out string
+ )
+
+ if format == "json" {
+ raw, err = json.MarshalIndent(obj, "", " ")
+ } else {
+ raw, err = yaml.Marshal(obj)
+ }
+
+ if err != nil {
+ return fmt.Sprintf("%s<failed to dump object: %v\n", indent(level), err)
+ }
+
+ for _, line := range strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n") {
+ out += indent(level) + line + "\n"
+ }
+
+ return out
+}
+
+func indent(level int) string {
+ format := fmt.Sprintf("%%%d.%ds", level, level)
+ return fmt.Sprintf(format, "")
+}
diff --git a/cmd/cdi/cmd/inject.go b/cmd/cdi/cmd/inject.go
new file mode 100644
index 0000000..752a374
--- /dev/null
+++ b/cmd/cdi/cmd/inject.go
@@ -0,0 +1,94 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ "sigs.k8s.io/yaml"
+
+ "github.com/spf13/cobra"
+)
+
+type injectFlags struct {
+ output string
+}
+
+// injectCmd is our command for injecting CDI devices into an OCI Spec.
+var injectCmd = &cobra.Command{
+ Aliases: []string{"inj", "in", "oci"},
+ Use: "inject <OCI Spec File> <CDI-device-list>",
+ Short: "Inject CDI devices into an OCI Spec",
+ Long: `
+The 'inject' command reads an OCI Spec from a file (use "-" for stdin),
+injects a requested set of CDI devices into it and dumps the resulting
+updated OCI Spec.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ if len(args) < 2 {
+ fmt.Printf("OCI Spec argument and devices expected\n")
+ os.Exit(1)
+ }
+
+ ociSpec, err := readOCISpec(args[0])
+ if err != nil {
+ fmt.Printf("%v\n", err)
+ os.Exit(1)
+ }
+ if err := cdiInjectDevices(injectCfg.output, ociSpec, args[1:]); err != nil {
+ fmt.Printf("%v\n", err)
+ os.Exit(1)
+ }
+ },
+}
+
+func readOCISpec(path string) (*oci.Spec, error) {
+ var (
+ spec *oci.Spec
+ data []byte
+ err error
+ )
+
+ if path == "-" {
+ data, err = ioutil.ReadAll(os.Stdin)
+ } else {
+ data, err = ioutil.ReadFile(path)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to read OCI Spec (%q): %w", path, err)
+ }
+
+ spec = &oci.Spec{}
+ if err = yaml.Unmarshal(data, spec); err != nil {
+ return nil, fmt.Errorf("failed to parse OCI Spec (%q): %w", path, err)
+ }
+
+ return spec, nil
+}
+
+var (
+ injectCfg injectFlags
+)
+
+func init() {
+ rootCmd.AddCommand(injectCmd)
+ injectCmd.Flags().StringVarP(&injectCfg.output,
+ "output", "o", "", "output format for OCI Spec (json|yaml)")
+}
diff --git a/cmd/cdi/cmd/monitor.go b/cmd/cdi/cmd/monitor.go
new file mode 100644
index 0000000..e259ebe
--- /dev/null
+++ b/cmd/cdi/cmd/monitor.go
@@ -0,0 +1,167 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/spf13/cobra"
+
+ cdi "tags.cncf.io/container-device-interface/pkg/cdi"
+)
+
+type monitorFlags struct {
+ verbose bool
+ output string
+}
+
+// monitorCmd is our command for monitoring CDI Spec refreshes.
+var monitorCmd = &cobra.Command{
+ Use: "monitor [specs] [vendors] [classes] [devices] [all]",
+ Short: "Monitor CDI Spec directories and refresh on changes",
+ Long: `
+The 'monitor' command monitors the CDI Spec directories and refreshes
+the cache upon changes. The arguments passed to monitor control what
+information to show upon each refresh.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ monitorSpecDirs(args...)
+ },
+}
+
+func monitorSpecDirs(args ...string) {
+ var (
+ registry = cdi.GetRegistry()
+ specDirs = registry.GetSpecDirectories()
+ dirWatch *fsnotify.Watcher
+ err error
+ done chan error
+ )
+
+ dirWatch, err = monitorDirectories(specDirs...)
+ if err != nil {
+ fmt.Printf("failed to set up CDI Spec dir monitoring: %v\n", err)
+ os.Exit(1)
+ }
+
+ for _, dir := range specDirs {
+ if _, err = os.Stat(dir); err != nil {
+ if !os.IsNotExist(err) {
+ fmt.Printf("failed to stat CDI Spec directory %s: %v\n", dir, err)
+ os.Exit(1)
+ }
+ fmt.Printf("WARNING: CDI Spec directory %s does not exist...\n", dir)
+ continue
+ }
+
+ if err = dirWatch.Add(dir); err != nil {
+ fmt.Printf("failed to watch CDI directory %q: %v\n", dir, err)
+ os.Exit(1)
+ }
+ }
+
+ done = make(chan error, 1)
+
+ go func() {
+ var (
+ // don't print registry content more often than this
+ oneSecond = 1 * time.Second
+ timer *time.Timer
+ )
+
+ if len(args) == 0 {
+ args = []string{"all"}
+ }
+
+ timer = time.NewTimer(oneSecond)
+ refresh := timer.C
+
+ defer func() {
+ timer.Stop()
+ }()
+
+ for {
+ select {
+ case _, ok := <-dirWatch.Events:
+ if !ok {
+ close(done)
+ return
+ }
+
+ if refresh != nil {
+ if !timer.Stop() {
+ <-timer.C
+ }
+ }
+ timer.Reset(oneSecond)
+ refresh = timer.C
+
+ case err, ok := <-dirWatch.Errors:
+ if ok {
+ done <- err
+ }
+ return
+
+ case _ = <-refresh:
+ refresh = nil
+ cdiPrintRegistry(args...)
+ }
+ }
+ }()
+
+ err = <-done
+ if err != nil {
+ fmt.Printf("CDI Spec watch failed: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func monitorDirectories(dirs ...string) (*fsnotify.Watcher, error) {
+ w, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create directory watch for %s: %w",
+ strings.Join(dirs, ","), err)
+ }
+
+ for _, dir := range dirs {
+ if _, err = os.Stat(dir); err != nil {
+ fmt.Printf("WARNING: failed to stat dir %q, NOT watching it...", dir)
+ continue
+ }
+
+ if err = w.Add(dir); err != nil {
+ return nil, fmt.Errorf("failed to add %q to fsnotify watch: %w", dir, err)
+ }
+ }
+
+ return w, nil
+}
+
+var (
+ monitorCfg monitorFlags
+)
+
+func init() {
+ rootCmd.AddCommand(monitorCmd)
+ monitorCmd.Flags().BoolVarP(&monitorCfg.verbose,
+ "verbose", "v", false, "print details")
+ monitorCmd.Flags().StringVarP(&monitorCfg.output,
+ "output", "o", "", "output format for details (json|yaml)")
+}
diff --git a/cmd/cdi/cmd/resolve.go b/cmd/cdi/cmd/resolve.go
new file mode 100644
index 0000000..37e23df
--- /dev/null
+++ b/cmd/cdi/cmd/resolve.go
@@ -0,0 +1,92 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+type resolveFlags struct {
+ output string
+}
+
+// resolveCmd is our command for resolving CDI devices present in an OCI Spec.
+var resolveCmd = &cobra.Command{
+ Aliases: []string{"res"},
+ Use: "resolve",
+ Short: "Resolve CDI devices present in an OCI Spec",
+ Long: `
+The 'resolve' command takes an OCI Spec file (use "-" for stdin),
+resolves any CDI Devices present in the Spec and dumps the result.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ if len(args) < 1 {
+ fmt.Printf("OCI Spec argument(s) expected\n")
+ os.Exit(1)
+ }
+ if err := cdiResolveDevices(args...); err != nil {
+ fmt.Printf("%v\n", err)
+ os.Exit(1)
+ }
+ },
+}
+
+/*
+func resolveDevices(ociSpecFiles ...string) error {
+ for _, ociSpecFile := range ociSpecFiles {
+ ociSpec, err := readOCISpec(ociSpecFile)
+ if err != nil {
+ return err
+ }
+
+ resolved, err := cdi.ResolveDevices(ociSpec)
+ if err != nil {
+ return errors.Wrapf(err, "CDI device resolution failed in %q",
+ ociSpecFile)
+ }
+
+ output := injectCfg.output
+ if output == "" {
+ if filepath.Ext(ociSpecFile) == ".json" {
+ output = "json"
+ } else {
+ output = "yaml"
+ }
+ }
+
+ if resolved != nil {
+ fmt.Printf("OCI Spec %q: resolved devices %q\n", ociSpecFile,
+ strings.Join(resolved, ", "))
+ fmt.Printf("%s", marshalObject(2, ociSpec, output))
+ }
+ }
+
+ return nil
+}
+*/
+
+var (
+ resolveCfg resolveFlags
+)
+
+func init() {
+ rootCmd.AddCommand(resolveCmd)
+ resolveCmd.Flags().StringVarP(&injectCfg.output,
+ "output", "o", "", "output format for OCI Spec (json|yaml)")
+}
diff --git a/cmd/cdi/cmd/root.go b/cmd/cdi/cmd/root.go
new file mode 100644
index 0000000..fed132f
--- /dev/null
+++ b/cmd/cdi/cmd/root.go
@@ -0,0 +1,78 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "tags.cncf.io/container-device-interface/pkg/cdi"
+ "tags.cncf.io/container-device-interface/pkg/cdi/validate"
+ "tags.cncf.io/container-device-interface/schema"
+)
+
+var (
+ specDirs []string
+ schemaName string
+)
+
+// rootCmd represents the base command when called without any subcommands
+var rootCmd = &cobra.Command{
+ Use: "cdi",
+ Short: "Inspect and interact with the CDI Registry",
+ Long: `
+The 'cdi' utility allows you to inspect and interact with the
+CDI Registry. Various commands are available for listing CDI
+Spec files, vendors, classes, devices, validating the content
+of the registry, injecting devices into OCI Specs, and for
+monitoring changes in the Registry.
+
+See cdi --help for a list of available commands. You can get
+additional help about <command> by using 'cdi <command> -h'.`,
+}
+
+// Execute adds all child commands to the root command and sets flags appropriately.
+// This is called by main.main(). It only needs to happen once to the rootCmd.
+func Execute() {
+ cobra.CheckErr(rootCmd.Execute())
+}
+
+func init() {
+ cobra.OnInitialize(initSpecDirs)
+ rootCmd.PersistentFlags().StringSliceVarP(&specDirs, "spec-dirs", "d", nil, "directories to scan for CDI Spec files")
+ rootCmd.PersistentFlags().StringVarP(&schemaName, "schema", "s", "builtin", "JSON schema to use for validation")
+}
+
+func initSpecDirs() {
+ s, err := schema.Load(schemaName)
+ if err != nil {
+ fmt.Printf("failed to load JSON schema %s: %v\n", schemaName, err)
+ os.Exit(1)
+ }
+ cdi.SetSpecValidator(validate.WithSchema(s))
+
+ if len(specDirs) > 0 {
+ cdi.GetRegistry(
+ cdi.WithSpecDirs(specDirs...),
+ )
+ if len(cdi.GetRegistry().GetErrors()) > 0 {
+ cdiPrintRegistryErrors()
+ }
+ }
+}
diff --git a/cmd/cdi/cmd/specs.go b/cmd/cdi/cmd/specs.go
new file mode 100644
index 0000000..60bd82f
--- /dev/null
+++ b/cmd/cdi/cmd/specs.go
@@ -0,0 +1,57 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "tags.cncf.io/container-device-interface/pkg/cdi"
+)
+
+type specFlags struct {
+ verbose bool
+ output string
+}
+
+// specsCmd is our command for listing Spec files.
+var specsCmd = &cobra.Command{
+ Use: "specs [vendor-list]",
+ Short: "List available CDI Specs",
+ Long: fmt.Sprintf(`
+The 'specs' command lists all CDI Specs present in the registry.
+If a vendor list is given, only CDI Specs by the given vendors are
+listed. The CDI Specs are discovered and loaded to the registry
+from CDI Spec directories. The default CDI Spec directories are:
+ %s.`, strings.Join(cdi.DefaultSpecDirs, ", ")),
+ Run: func(cmd *cobra.Command, vendors []string) {
+ cdiListSpecs(specCfg.verbose, specCfg.output, vendors...)
+ },
+}
+
+var (
+ specCfg specFlags
+)
+
+func init() {
+ rootCmd.AddCommand(specsCmd)
+ specsCmd.Flags().BoolVarP(&specCfg.verbose,
+ "verbose", "v", false, "list CDI Spec details")
+ specsCmd.Flags().StringVarP(&specCfg.output,
+ "output", "o", "", "output format for details (json|yaml)")
+}
diff --git a/cmd/cdi/cmd/validate.go b/cmd/cdi/cmd/validate.go
new file mode 100644
index 0000000..c96e43d
--- /dev/null
+++ b/cmd/cdi/cmd/validate.go
@@ -0,0 +1,57 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "tags.cncf.io/container-device-interface/pkg/cdi"
+)
+
+// validateCmd is our CDI command for validating CDI Spec files in the registry.
+var validateCmd = &cobra.Command{
+ Use: "validate",
+ Short: "List CDI registry errors",
+ Long: `
+The 'validate' command lists errors encountered during the population
+of the CDI registry. It exits with an exit status of 1 if any errors
+were reported by the registry.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ cdiErrors := cdi.GetRegistry().GetErrors()
+ if len(cdiErrors) == 0 {
+ fmt.Printf("No CDI Registry errors.\n")
+ return
+ }
+
+ fmt.Printf("CDI Registry has errors:\n")
+ for path, specErrors := range cdiErrors {
+ fmt.Printf("Spec file %s:\n", path)
+ for idx, err := range specErrors {
+ fmt.Printf(" %2d: %v\n", idx, strings.TrimSpace(err.Error()))
+ }
+ }
+ os.Exit(1)
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(validateCmd)
+}
diff --git a/cmd/cdi/cmd/vendors.go b/cmd/cdi/cmd/vendors.go
new file mode 100644
index 0000000..c82bec3
--- /dev/null
+++ b/cmd/cdi/cmd/vendors.go
@@ -0,0 +1,35 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+// vendorsCmd is our command for listing vendors.
+var vendorsCmd = &cobra.Command{
+ Use: "vendors",
+ Short: "List vendors",
+ Long: `List vendors with CDI Specs in the registry.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ cdiListVendors()
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(vendorsCmd)
+}
diff --git a/cmd/cdi/go.mod b/cmd/cdi/go.mod
new file mode 100644
index 0000000..f270f31
--- /dev/null
+++ b/cmd/cdi/go.mod
@@ -0,0 +1,30 @@
+module tags.cncf.io/container-device-interface/cmd/cdi
+
+go 1.19
+
+require (
+ github.com/fsnotify/fsnotify v1.5.1
+ github.com/opencontainers/runtime-spec v1.1.0
+ github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626
+ github.com/spf13/cobra v1.6.0
+ sigs.k8s.io/yaml v1.3.0
+ tags.cncf.io/container-device-interface v0.0.0
+)
+
+require (
+ github.com/inconshreveable/mousetrap v1.0.1 // indirect
+ github.com/opencontainers/selinux v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
+ github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
+ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
+ github.com/xeipuuv/gojsonschema v1.2.0 // indirect
+ golang.org/x/mod v0.4.2 // indirect
+ golang.org/x/sys v0.1.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ tags.cncf.io/container-device-interface/specs-go v0.6.0 // indirect
+)
+
+replace tags.cncf.io/container-device-interface => ../..
+
+replace tags.cncf.io/container-device-interface/specs-go => ../../specs-go
diff --git a/cmd/cdi/go.sum b/cmd/cdi/go.sum
new file mode 100644
index 0000000..b4892fc
--- /dev/null
+++ b/cmd/cdi/go.sum
@@ -0,0 +1,73 @@
+github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
+github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
+github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/mndrix/tap-go v0.0.0-20171203230836-629fa407e90b/go.mod h1:pzzDgJWZ34fGzaAZGFW22KVZDfyrYW+QABMrWnJBnSs=
+github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
+github.com/opencontainers/runtime-spec v1.0.3-0.20220825212826-86290f6a00fb/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg=
+github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 h1:DmNGcqH3WDbV5k8OJ+esPWbqUOX5rMLR2PMvziDMJi0=
+github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626/go.mod h1:BRHJJd0E+cx42OybVYSgUvZmU0B8P9gZuRXlZUP7TKI=
+github.com/opencontainers/selinux v1.9.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI=
+github.com/opencontainers/selinux v1.10.0 h1:rAiKF8hTcgLI3w0DHm6i0ylVVcOrlgR1kK99DRLDhyU=
+github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI=
+github.com/spf13/cobra v1.6.0/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
+github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
+github.com/urfave/cli v1.19.1/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
+sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
diff --git a/cmd/cdi/main.go b/cmd/cdi/main.go
new file mode 100644
index 0000000..f4024a9
--- /dev/null
+++ b/cmd/cdi/main.go
@@ -0,0 +1,23 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package main
+
+import "tags.cncf.io/container-device-interface/cmd/cdi/cmd"
+
+func main() {
+ cmd.Execute()
+}
diff --git a/cmd/validate/go.mod b/cmd/validate/go.mod
new file mode 100644
index 0000000..29341d6
--- /dev/null
+++ b/cmd/validate/go.mod
@@ -0,0 +1,17 @@
+module tags.cncf.io/container-device-interface/cmd/validate
+
+go 1.19
+
+require tags.cncf.io/container-device-interface v0.0.0
+
+require (
+ github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
+ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
+ github.com/xeipuuv/gojsonschema v1.2.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ sigs.k8s.io/yaml v1.3.0 // indirect
+)
+
+replace tags.cncf.io/container-device-interface => ../..
+
+replace tags.cncf.io/container-device-interface/specs-go => ../../specs-go
diff --git a/cmd/validate/go.sum b/cmd/validate/go.sum
new file mode 100644
index 0000000..8ba012f
--- /dev/null
+++ b/cmd/validate/go.sum
@@ -0,0 +1,27 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
+github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg=
+github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 h1:DmNGcqH3WDbV5k8OJ+esPWbqUOX5rMLR2PMvziDMJi0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
+golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
+sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
diff --git a/cmd/validate/validate.go b/cmd/validate/validate.go
new file mode 100644
index 0000000..8971f2b
--- /dev/null
+++ b/cmd/validate/validate.go
@@ -0,0 +1,102 @@
+/*
+ * Original code from: https://github.com/opencontainers/runtime-spec/blob/643c1429d905bba70fe977bae274f367ad101e73/schema/validate.go
+ * Changes:
+ * - Output errors to stderr
+ * - Refactored to use package-internal validation library
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package main
+
+import (
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "os"
+
+ "tags.cncf.io/container-device-interface/schema"
+)
+
+const usage = `Validate is used to check document with specified schema.
+You can use validate in following ways:
+
+ 1.specify document file as an argument
+ validate --schema <schema.json> <document.json>
+
+ 2.pass document content through a pipe
+ cat <document.json> | validate --schema <schema.json>
+
+ 3.input document content manually, ended with ctrl+d(or your self-defined EOF keys)
+ validate --schema <schema.json>
+ [INPUT DOCUMENT CONTENT HERE]
+`
+
+func main() {
+ var (
+ schemaFile string
+ docFile string
+ docData []byte
+ err error
+ exitCode int
+ )
+
+ flag.Usage = func() {
+ fmt.Fprintf(flag.CommandLine.Output(), "%s\n", usage)
+ fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0])
+ flag.PrintDefaults()
+ }
+
+ flag.StringVar(&schemaFile, "schema", "builtin", "JSON Schema to validate against")
+ flag.Parse()
+
+ if schemaFile != "" {
+ scm, err := schema.Load(schemaFile)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "failed to load schema %s: %v\n", schemaFile, err)
+ os.Exit(1)
+ }
+ schema.Set(scm)
+ fmt.Printf("Validating against JSON schema %s...\n", schemaFile)
+ } else {
+ fmt.Printf("Validating against builtin JSON schema...\n")
+ }
+
+ docs := flag.Args()
+ if len(docs) == 0 {
+ docs = []string{"-"}
+ }
+
+ for _, docFile = range docs {
+ if docFile == "" || docFile == "-" {
+ docFile = "<stdin>"
+ docData, err = ioutil.ReadAll(os.Stdin)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "failed to read document data from stdin: %v\n", err)
+ os.Exit(1)
+ }
+ err = schema.ValidateData(docData)
+ } else {
+ err = schema.ValidateFile(docFile)
+ }
+
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s: validation failed:\n %v\n", docFile, err)
+ exitCode = 1
+ } else {
+ fmt.Printf("%s: document is valid.\n", docFile)
+ }
+ }
+
+ os.Exit(exitCode)
+}
diff --git a/code-of-conduct.md b/code-of-conduct.md
new file mode 100644
index 0000000..644ae80
--- /dev/null
+++ b/code-of-conduct.md
@@ -0,0 +1,3 @@
+# Code of Conduct
+
+Please refer to the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md).
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..3ba5b19
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,27 @@
+module tags.cncf.io/container-device-interface
+
+go 1.19
+
+require (
+ github.com/fsnotify/fsnotify v1.5.1
+ github.com/opencontainers/runtime-spec v1.1.0
+ github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626
+ github.com/stretchr/testify v1.7.0
+ github.com/xeipuuv/gojsonschema v1.2.0
+ golang.org/x/mod v0.4.2
+ golang.org/x/sys v0.1.0
+ sigs.k8s.io/yaml v1.3.0
+ tags.cncf.io/container-device-interface/specs-go v0.6.0
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
+ github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
+ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+replace tags.cncf.io/container-device-interface/specs-go => ./specs-go
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d7ac866
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,66 @@
+github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
+github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/mndrix/tap-go v0.0.0-20171203230836-629fa407e90b/go.mod h1:pzzDgJWZ34fGzaAZGFW22KVZDfyrYW+QABMrWnJBnSs=
+github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
+github.com/opencontainers/runtime-spec v1.0.3-0.20220825212826-86290f6a00fb/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg=
+github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 h1:DmNGcqH3WDbV5k8OJ+esPWbqUOX5rMLR2PMvziDMJi0=
+github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626/go.mod h1:BRHJJd0E+cx42OybVYSgUvZmU0B8P9gZuRXlZUP7TKI=
+github.com/opencontainers/selinux v1.9.1 h1:b4VPEF3O5JLZgdTDBmGepaaIbAo0GqoF6EBRq5f/g3Y=
+github.com/opencontainers/selinux v1.9.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
+github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
+github.com/urfave/cli v1.19.1/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
+sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
diff --git a/internal/multierror/multierror.go b/internal/multierror/multierror.go
new file mode 100644
index 0000000..07aca4a
--- /dev/null
+++ b/internal/multierror/multierror.go
@@ -0,0 +1,82 @@
+/*
+ Copyright © 2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package multierror
+
+import (
+ "strings"
+)
+
+// New combines several errors into a single error. Parameters that are nil are
+// ignored. If no errors are passed in or all parameters are nil, then the
+// result is also nil.
+func New(errors ...error) error {
+ // Filter out nil entries.
+ numErrors := 0
+ for _, err := range errors {
+ if err != nil {
+ errors[numErrors] = err
+ numErrors++
+ }
+ }
+ if numErrors == 0 {
+ return nil
+ }
+ return multiError(errors[0:numErrors])
+}
+
+// multiError is the underlying implementation used by New.
+//
+// Beware that a null multiError is not the same as a nil error.
+type multiError []error
+
+// multiError returns all individual error strings concatenated with "\n"
+func (e multiError) Error() string {
+ var builder strings.Builder
+ for i, err := range e {
+ if i > 0 {
+ _, _ = builder.WriteString("\n")
+ }
+ _, _ = builder.WriteString(err.Error())
+ }
+ return builder.String()
+}
+
+// Append returns a new multi error all errors concatenated. Errors that are
+// multi errors get flattened, nil is ignored.
+func Append(err error, errors ...error) error {
+ var result multiError
+ if m, ok := err.(multiError); ok {
+ result = m
+ } else if err != nil {
+ result = append(result, err)
+ }
+
+ for _, e := range errors {
+ if e == nil {
+ continue
+ }
+ if m, ok := e.(multiError); ok {
+ result = append(result, m...)
+ } else {
+ result = append(result, e)
+ }
+ }
+ if len(result) == 0 {
+ return nil
+ }
+ return result
+}
diff --git a/internal/multierror/multierror_test.go b/internal/multierror/multierror_test.go
new file mode 100644
index 0000000..173c58c
--- /dev/null
+++ b/internal/multierror/multierror_test.go
@@ -0,0 +1,38 @@
+/*
+ Copyright © 2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package multierror
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNew(t *testing.T) {
+ assert.Equal(t, nil, New())
+ assert.Equal(t, nil, New(nil))
+ assert.Equal(t, nil, New(nil, nil))
+ assert.Equal(t, "hello\nworld", New(errors.New("hello"), errors.New("world")).Error())
+}
+
+func TestAppend(t *testing.T) {
+ assert.Equal(t, nil, Append(nil))
+ assert.Equal(t, nil, Append(nil, nil))
+ assert.Equal(t, multiError{errors.New("hello"), errors.New("world"), errors.New("x"), errors.New("y")},
+ Append(New(errors.New("hello"), errors.New("world")), New(errors.New("x"), nil, errors.New("y"))), nil)
+}
diff --git a/internal/validation/k8s/objectmeta.go b/internal/validation/k8s/objectmeta.go
new file mode 100644
index 0000000..5cf63da
--- /dev/null
+++ b/internal/validation/k8s/objectmeta.go
@@ -0,0 +1,57 @@
+/*
+Copyright 2014 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Adapted from k8s.io/apimachinery/pkg/api/validation:
+// https://github.com/kubernetes/apimachinery/blob/7687996c715ee7d5c8cf1e3215e607eb065a4221/pkg/api/validation/objectmeta.go
+
+package k8s
+
+import (
+ "fmt"
+ "strings"
+
+ "tags.cncf.io/container-device-interface/internal/multierror"
+)
+
+// TotalAnnotationSizeLimitB defines the maximum size of all annotations in characters.
+const TotalAnnotationSizeLimitB int = 256 * (1 << 10) // 256 kB
+
+// ValidateAnnotations validates that a set of annotations are correctly defined.
+func ValidateAnnotations(annotations map[string]string, path string) error {
+ errors := multierror.New()
+ for k := range annotations {
+ // The rule is QualifiedName except that case doesn't matter, so convert to lowercase before checking.
+ for _, msg := range IsQualifiedName(strings.ToLower(k)) {
+ errors = multierror.Append(errors, fmt.Errorf("%v.%v is invalid: %v", path, k, msg))
+ }
+ }
+ if err := ValidateAnnotationsSize(annotations); err != nil {
+ errors = multierror.Append(errors, fmt.Errorf("%v is too long: %v", path, err))
+ }
+ return errors
+}
+
+// ValidateAnnotationsSize validates that a set of annotations is not too large.
+func ValidateAnnotationsSize(annotations map[string]string) error {
+ var totalSize int64
+ for k, v := range annotations {
+ totalSize += (int64)(len(k)) + (int64)(len(v))
+ }
+ if totalSize > (int64)(TotalAnnotationSizeLimitB) {
+ return fmt.Errorf("annotations size %d is larger than limit %d", totalSize, TotalAnnotationSizeLimitB)
+ }
+ return nil
+}
diff --git a/internal/validation/k8s/validation.go b/internal/validation/k8s/validation.go
new file mode 100644
index 0000000..5ad6ce2
--- /dev/null
+++ b/internal/validation/k8s/validation.go
@@ -0,0 +1,217 @@
+/*
+Copyright 2014 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Adapted from k8s.io/apimachinery/pkg/util/validation:
+// https://github.com/kubernetes/apimachinery/blob/7687996c715ee7d5c8cf1e3215e607eb065a4221/pkg/util/validation/validation.go
+
+package k8s
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+const qnameCharFmt string = "[A-Za-z0-9]"
+const qnameExtCharFmt string = "[-A-Za-z0-9_.]"
+const qualifiedNameFmt string = "(" + qnameCharFmt + qnameExtCharFmt + "*)?" + qnameCharFmt
+const qualifiedNameErrMsg string = "must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character"
+const qualifiedNameMaxLength int = 63
+
+var qualifiedNameRegexp = regexp.MustCompile("^" + qualifiedNameFmt + "$")
+
+// IsQualifiedName tests whether the value passed is what Kubernetes calls a
+// "qualified name". This is a format used in various places throughout the
+// system. If the value is not valid, a list of error strings is returned.
+// Otherwise an empty list (or nil) is returned.
+func IsQualifiedName(value string) []string {
+ var errs []string
+ parts := strings.Split(value, "/")
+ var name string
+ switch len(parts) {
+ case 1:
+ name = parts[0]
+ case 2:
+ var prefix string
+ prefix, name = parts[0], parts[1]
+ if len(prefix) == 0 {
+ errs = append(errs, "prefix part "+EmptyError())
+ } else if msgs := IsDNS1123Subdomain(prefix); len(msgs) != 0 {
+ errs = append(errs, prefixEach(msgs, "prefix part ")...)
+ }
+ default:
+ return append(errs, "a qualified name "+RegexError(qualifiedNameErrMsg, qualifiedNameFmt, "MyName", "my.name", "123-abc")+
+ " with an optional DNS subdomain prefix and '/' (e.g. 'example.com/MyName')")
+ }
+
+ if len(name) == 0 {
+ errs = append(errs, "name part "+EmptyError())
+ } else if len(name) > qualifiedNameMaxLength {
+ errs = append(errs, "name part "+MaxLenError(qualifiedNameMaxLength))
+ }
+ if !qualifiedNameRegexp.MatchString(name) {
+ errs = append(errs, "name part "+RegexError(qualifiedNameErrMsg, qualifiedNameFmt, "MyName", "my.name", "123-abc"))
+ }
+ return errs
+}
+
+const labelValueFmt string = "(" + qualifiedNameFmt + ")?"
+const labelValueErrMsg string = "a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character"
+
+// LabelValueMaxLength is a label's max length
+const LabelValueMaxLength int = 63
+
+var labelValueRegexp = regexp.MustCompile("^" + labelValueFmt + "$")
+
+// IsValidLabelValue tests whether the value passed is a valid label value. If
+// the value is not valid, a list of error strings is returned. Otherwise an
+// empty list (or nil) is returned.
+func IsValidLabelValue(value string) []string {
+ var errs []string
+ if len(value) > LabelValueMaxLength {
+ errs = append(errs, MaxLenError(LabelValueMaxLength))
+ }
+ if !labelValueRegexp.MatchString(value) {
+ errs = append(errs, RegexError(labelValueErrMsg, labelValueFmt, "MyValue", "my_value", "12345"))
+ }
+ return errs
+}
+
+const dns1123LabelFmt string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?"
+const dns1123LabelErrMsg string = "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character"
+
+// DNS1123LabelMaxLength is a label's max length in DNS (RFC 1123)
+const DNS1123LabelMaxLength int = 63
+
+var dns1123LabelRegexp = regexp.MustCompile("^" + dns1123LabelFmt + "$")
+
+// IsDNS1123Label tests for a string that conforms to the definition of a label in
+// DNS (RFC 1123).
+func IsDNS1123Label(value string) []string {
+ var errs []string
+ if len(value) > DNS1123LabelMaxLength {
+ errs = append(errs, MaxLenError(DNS1123LabelMaxLength))
+ }
+ if !dns1123LabelRegexp.MatchString(value) {
+ errs = append(errs, RegexError(dns1123LabelErrMsg, dns1123LabelFmt, "my-name", "123-abc"))
+ }
+ return errs
+}
+
+const dns1123SubdomainFmt string = dns1123LabelFmt + "(\\." + dns1123LabelFmt + ")*"
+const dns1123SubdomainErrorMsg string = "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character"
+
+// DNS1123SubdomainMaxLength is a subdomain's max length in DNS (RFC 1123)
+const DNS1123SubdomainMaxLength int = 253
+
+var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$")
+
+// IsDNS1123Subdomain tests for a string that conforms to the definition of a
+// subdomain in DNS (RFC 1123).
+func IsDNS1123Subdomain(value string) []string {
+ var errs []string
+ if len(value) > DNS1123SubdomainMaxLength {
+ errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength))
+ }
+ if !dns1123SubdomainRegexp.MatchString(value) {
+ errs = append(errs, RegexError(dns1123SubdomainErrorMsg, dns1123SubdomainFmt, "example.com"))
+ }
+ return errs
+}
+
+const dns1035LabelFmt string = "[a-z]([-a-z0-9]*[a-z0-9])?"
+const dns1035LabelErrMsg string = "a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character"
+
+// DNS1035LabelMaxLength is a label's max length in DNS (RFC 1035)
+const DNS1035LabelMaxLength int = 63
+
+var dns1035LabelRegexp = regexp.MustCompile("^" + dns1035LabelFmt + "$")
+
+// IsDNS1035Label tests for a string that conforms to the definition of a label in
+// DNS (RFC 1035).
+func IsDNS1035Label(value string) []string {
+ var errs []string
+ if len(value) > DNS1035LabelMaxLength {
+ errs = append(errs, MaxLenError(DNS1035LabelMaxLength))
+ }
+ if !dns1035LabelRegexp.MatchString(value) {
+ errs = append(errs, RegexError(dns1035LabelErrMsg, dns1035LabelFmt, "my-name", "abc-123"))
+ }
+ return errs
+}
+
+// wildcard definition - RFC 1034 section 4.3.3.
+// examples:
+// - valid: *.bar.com, *.foo.bar.com
+// - invalid: *.*.bar.com, *.foo.*.com, *bar.com, f*.bar.com, *
+const wildcardDNS1123SubdomainFmt = "\\*\\." + dns1123SubdomainFmt
+const wildcardDNS1123SubdomainErrMsg = "a wildcard DNS-1123 subdomain must start with '*.', followed by a valid DNS subdomain, which must consist of lower case alphanumeric characters, '-' or '.' and end with an alphanumeric character"
+
+// IsWildcardDNS1123Subdomain tests for a string that conforms to the definition of a
+// wildcard subdomain in DNS (RFC 1034 section 4.3.3).
+func IsWildcardDNS1123Subdomain(value string) []string {
+ wildcardDNS1123SubdomainRegexp := regexp.MustCompile("^" + wildcardDNS1123SubdomainFmt + "$")
+
+ var errs []string
+ if len(value) > DNS1123SubdomainMaxLength {
+ errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength))
+ }
+ if !wildcardDNS1123SubdomainRegexp.MatchString(value) {
+ errs = append(errs, RegexError(wildcardDNS1123SubdomainErrMsg, wildcardDNS1123SubdomainFmt, "*.example.com"))
+ }
+ return errs
+}
+
+// MaxLenError returns a string explanation of a "string too long" validation
+// failure.
+func MaxLenError(length int) string {
+ return fmt.Sprintf("must be no more than %d characters", length)
+}
+
+// RegexError returns a string explanation of a regex validation failure.
+func RegexError(msg string, fmt string, examples ...string) string {
+ if len(examples) == 0 {
+ return msg + " (regex used for validation is '" + fmt + "')"
+ }
+ msg += " (e.g. "
+ for i := range examples {
+ if i > 0 {
+ msg += " or "
+ }
+ msg += "'" + examples[i] + "', "
+ }
+ msg += "regex used for validation is '" + fmt + "')"
+ return msg
+}
+
+// EmptyError returns a string explanation of a "must not be empty" validation
+// failure.
+func EmptyError() string {
+ return "must be non-empty"
+}
+
+func prefixEach(msgs []string, prefix string) []string {
+ for i := range msgs {
+ msgs[i] = prefix + msgs[i]
+ }
+ return msgs
+}
+
+// InclusiveRangeError returns a string explanation of a numeric "must be
+// between" validation failure.
+func InclusiveRangeError(lo, hi int) string {
+ return fmt.Sprintf(`must be between %d and %d, inclusive`, lo, hi)
+}
diff --git a/internal/validation/validate.go b/internal/validation/validate.go
new file mode 100644
index 0000000..5d9b55f
--- /dev/null
+++ b/internal/validation/validate.go
@@ -0,0 +1,56 @@
+/*
+ Copyright © The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package validation
+
+import (
+ "fmt"
+ "strings"
+
+ "tags.cncf.io/container-device-interface/internal/validation/k8s"
+)
+
+// ValidateSpecAnnotations checks whether spec annotations are valid.
+func ValidateSpecAnnotations(name string, any interface{}) error {
+ if any == nil {
+ return nil
+ }
+
+ switch v := any.(type) {
+ case map[string]interface{}:
+ annotations := make(map[string]string)
+ for k, v := range v {
+ if s, ok := v.(string); ok {
+ annotations[k] = s
+ } else {
+ return fmt.Errorf("invalid annotation %v.%v; %v is not a string", name, k, any)
+ }
+ }
+ return validateSpecAnnotations(name, annotations)
+ }
+
+ return nil
+}
+
+// validateSpecAnnotations checks whether spec annotations are valid.
+func validateSpecAnnotations(name string, annotations map[string]string) error {
+ path := "annotations"
+ if name != "" {
+ path = strings.Join([]string{name, path}, ".")
+ }
+
+ return k8s.ValidateAnnotations(annotations, path)
+}
diff --git a/pkg/cdi/annotations.go b/pkg/cdi/annotations.go
new file mode 100644
index 0000000..a38b0f1
--- /dev/null
+++ b/pkg/cdi/annotations.go
@@ -0,0 +1,141 @@
+/*
+ Copyright © 2021-2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "tags.cncf.io/container-device-interface/pkg/parser"
+)
+
+const (
+ // AnnotationPrefix is the prefix for CDI container annotation keys.
+ AnnotationPrefix = "cdi.k8s.io/"
+)
+
+// UpdateAnnotations updates annotations with a plugin-specific CDI device
+// injection request for the given devices. Upon any error a non-nil error
+// is returned and annotations are left intact. By convention plugin should
+// be in the format of "vendor.device-type".
+func UpdateAnnotations(annotations map[string]string, plugin string, deviceID string, devices []string) (map[string]string, error) {
+ key, err := AnnotationKey(plugin, deviceID)
+ if err != nil {
+ return annotations, fmt.Errorf("CDI annotation failed: %w", err)
+ }
+ if _, ok := annotations[key]; ok {
+ return annotations, fmt.Errorf("CDI annotation failed, key %q used", key)
+ }
+ value, err := AnnotationValue(devices)
+ if err != nil {
+ return annotations, fmt.Errorf("CDI annotation failed: %w", err)
+ }
+
+ if annotations == nil {
+ annotations = make(map[string]string)
+ }
+ annotations[key] = value
+
+ return annotations, nil
+}
+
+// ParseAnnotations parses annotations for CDI device injection requests.
+// The keys and devices from all such requests are collected into slices
+// which are returned as the result. All devices are expected to be fully
+// qualified CDI device names. If any device fails this check empty slices
+// are returned along with a non-nil error. The annotations are expected
+// to be formatted by, or in a compatible fashion to UpdateAnnotations().
+func ParseAnnotations(annotations map[string]string) ([]string, []string, error) {
+ var (
+ keys []string
+ devices []string
+ )
+
+ for key, value := range annotations {
+ if !strings.HasPrefix(key, AnnotationPrefix) {
+ continue
+ }
+ for _, d := range strings.Split(value, ",") {
+ if !IsQualifiedName(d) {
+ return nil, nil, fmt.Errorf("invalid CDI device name %q", d)
+ }
+ devices = append(devices, d)
+ }
+ keys = append(keys, key)
+ }
+
+ return keys, devices, nil
+}
+
+// AnnotationKey returns a unique annotation key for an device allocation
+// by a K8s device plugin. pluginName should be in the format of
+// "vendor.device-type". deviceID is the ID of the device the plugin is
+// allocating. It is used to make sure that the generated key is unique
+// even if multiple allocations by a single plugin needs to be annotated.
+func AnnotationKey(pluginName, deviceID string) (string, error) {
+ const maxNameLen = 63
+
+ if pluginName == "" {
+ return "", errors.New("invalid plugin name, empty")
+ }
+ if deviceID == "" {
+ return "", errors.New("invalid deviceID, empty")
+ }
+
+ name := pluginName + "_" + strings.ReplaceAll(deviceID, "/", "_")
+
+ if len(name) > maxNameLen {
+ return "", fmt.Errorf("invalid plugin+deviceID %q, too long", name)
+ }
+
+ if c := rune(name[0]); !parser.IsAlphaNumeric(c) {
+ return "", fmt.Errorf("invalid name %q, first '%c' should be alphanumeric",
+ name, c)
+ }
+ if len(name) > 2 {
+ for _, c := range name[1 : len(name)-1] {
+ switch {
+ case parser.IsAlphaNumeric(c):
+ case c == '_' || c == '-' || c == '.':
+ default:
+ return "", fmt.Errorf("invalid name %q, invalid character '%c'",
+ name, c)
+ }
+ }
+ }
+ if c := rune(name[len(name)-1]); !parser.IsAlphaNumeric(c) {
+ return "", fmt.Errorf("invalid name %q, last '%c' should be alphanumeric",
+ name, c)
+ }
+
+ return AnnotationPrefix + name, nil
+}
+
+// AnnotationValue returns an annotation value for the given devices.
+func AnnotationValue(devices []string) (string, error) {
+ value, sep := "", ""
+ for _, d := range devices {
+ if _, _, _, err := ParseQualifiedName(d); err != nil {
+ return "", err
+ }
+ value += sep + d
+ sep = ","
+ }
+
+ return value, nil
+}
diff --git a/pkg/cdi/annotations_test.go b/pkg/cdi/annotations_test.go
new file mode 100644
index 0000000..a253c22
--- /dev/null
+++ b/pkg/cdi/annotations_test.go
@@ -0,0 +1,458 @@
+/*
+ Copyright © 2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "sort"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAnnotationKey(t *testing.T) {
+ type testCase = struct {
+ name string
+ plugin string
+ devID string
+ key string
+ invalid bool
+ }
+
+ for _, tc := range []*testCase{
+ {
+ name: "invalid, empty plugin",
+ plugin: "",
+ invalid: true,
+ },
+ {
+ name: "invalid, empty device ID",
+ plugin: "plugin",
+ devID: "",
+ invalid: true,
+ },
+ {
+ name: "invalid, non-alphanumeric first character",
+ plugin: "_vendor.class",
+ devID: "device",
+ invalid: true,
+ },
+ {
+ name: "invalid, non-alphanumeric last character",
+ plugin: "vendor.class",
+ devID: "device_",
+ invalid: true,
+ },
+ {
+ name: "invalid, plugin contains invalid characters",
+ plugin: "ven.dor-cl+ass",
+ devID: "device",
+ invalid: true,
+ },
+ {
+ name: "invalid, devID contains invalid characters",
+ plugin: "vendor.class",
+ devID: "dev+ice",
+ invalid: true,
+ },
+ {
+ name: "invalid, too plugin long",
+ plugin: "123456789012345678901234567890123456789012345678901234567",
+ devID: "device",
+ invalid: true,
+ },
+ {
+ name: "valid, simple",
+ plugin: "vendor.class",
+ devID: "device",
+ key: AnnotationPrefix + "vendor.class" + "_" + "device",
+ },
+ {
+ name: "valid, with special characters",
+ plugin: "v-e.n_d.or.cl-as_s",
+ devID: "d_e-v-i-c_e",
+ key: AnnotationPrefix + "v-e.n_d.or.cl-as_s" + "_" + "d_e-v-i-c_e",
+ },
+ {
+ name: "valid, with /'s replaced in devID",
+ plugin: "v-e.n_d.or.cl-as_s",
+ devID: "d-e/v/i/c-e",
+ key: AnnotationPrefix + "v-e.n_d.or.cl-as_s" + "_" + "d-e_v_i_c-e",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ key, err := AnnotationKey(tc.plugin, tc.devID)
+ if !tc.invalid {
+ require.NoError(t, err, "annotation key")
+ require.Equal(t, tc.key, key, "annotation key")
+ } else {
+ require.Error(t, err)
+ }
+ })
+ }
+}
+
+func TestUpdateAnnotations(t *testing.T) {
+ type inject = struct {
+ plugin string
+ devID string
+ devices []string
+ }
+ type testCase = struct {
+ name string
+ existing map[string]string
+ injections []*inject
+ annotations map[string]string
+ parsed []string
+ invalid bool
+ }
+
+ for _, tc := range []*testCase{
+ {
+ name: "one plugin, one device",
+ injections: []*inject{
+ {
+ plugin: "vendor.class",
+ devID: "device",
+ devices: []string{
+ "vendor.com/class=device",
+ },
+ },
+ },
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor.class_device": "vendor.com/class=device",
+ },
+ parsed: []string{
+ "vendor.com/class=device",
+ },
+ },
+ {
+ name: "one plugin, multiple devices",
+ injections: []*inject{
+ {
+ plugin: "vendor.class",
+ devID: "device",
+ devices: []string{
+ "vendor.com/class=device1",
+ "vendor.com/class=device2",
+ "vendor.com/class=device3",
+ },
+ },
+ },
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor.class_device": "vendor.com/class=device1,vendor.com/class=device2,vendor.com/class=device3",
+ },
+ parsed: []string{
+ "vendor.com/class=device1",
+ "vendor.com/class=device2",
+ "vendor.com/class=device3",
+ },
+ },
+ {
+ name: "multiple plugins, multiple devices",
+ injections: []*inject{
+ {
+ plugin: "vendor1.class",
+ devID: "device1",
+ devices: []string{
+ "vendor1.com/class=device1",
+ },
+ },
+ {
+ plugin: "vendor1.class",
+ devID: "device2",
+ devices: []string{
+ "vendor2.com/class=device1",
+ "vendor2.com/class=device2",
+ },
+ },
+ {
+ plugin: "vendor3.class2",
+ devID: "device",
+ devices: []string{
+ "vendor3.com/class2=device1",
+ "vendor3.com/class2=device2",
+ "vendor3.com/class2=device3",
+ },
+ },
+ },
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor1.class_device1": "vendor1.com/class=device1",
+ AnnotationPrefix + "vendor1.class_device2": "vendor2.com/class=device1,vendor2.com/class=device2",
+ AnnotationPrefix + "vendor3.class2_device": "vendor3.com/class2=device1,vendor3.com/class2=device2,vendor3.com/class2=device3",
+ },
+ parsed: []string{
+ "vendor1.com/class=device1",
+ "vendor2.com/class=device1",
+ "vendor2.com/class=device2",
+ "vendor3.com/class2=device1",
+ "vendor3.com/class2=device2",
+ "vendor3.com/class2=device3",
+ },
+ },
+ {
+ name: "invalid, empty plugin",
+ injections: []*inject{
+ {
+ plugin: "vendor1.class",
+ devID: "device",
+ devices: []string{
+ "vendor1.com/class=device1",
+ },
+ },
+ {
+ plugin: "vendor2.class",
+ devID: "device",
+ devices: []string{
+ "vendor2.com/class=device1",
+ "vendor2.com/class=device2",
+ },
+ },
+ {
+ plugin: "",
+ devID: "device",
+ devices: []string{
+ "vendor3.com/class2=device1",
+ "vendor3.com/class2=device2",
+ "vendor3.com/class2=device3",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid, malformed device reference",
+ injections: []*inject{
+ {
+ plugin: "vendor1.class",
+ devID: "device",
+ devices: []string{
+ "vendor1.com/class=device1",
+ },
+ },
+ {
+ plugin: "vendor2.class",
+ devID: "device",
+ devices: []string{
+ "vendor2.com/class=device1",
+ "vendor2.com/device2",
+ },
+ },
+ {
+ plugin: "vendor3.class2",
+ devID: "device",
+ devices: []string{
+ "vendor3.com/class2=device1",
+ "vendor3.com/class2=device2",
+ "vendor3.com/class2=device3",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid, pre-resolved device",
+ injections: []*inject{
+ {
+ plugin: "vendor1.class",
+ devID: "device",
+ devices: []string{
+ "vendor1.com/class=device1",
+ },
+ },
+ {
+ plugin: "vendor2.class",
+ devID: "device",
+ devices: []string{
+ "vendor2.com/class=device1",
+ "vendor2.com/class=device2",
+ },
+ },
+ {
+ plugin: "vendor3.class2",
+ devID: "device",
+ devices: []string{
+ "vendor3.com/class2=device1",
+ "vendor3.com/class2=device2",
+ "/dev/null",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid, conflicting keys",
+ existing: map[string]string{
+ AnnotationPrefix + "vendor3.class2_device": "vendor3.com/class2=device0",
+ },
+ injections: []*inject{
+ {
+ plugin: "vendor1.class",
+ devID: "device",
+ devices: []string{
+ "vendor1.com/class=device1",
+ },
+ },
+ {
+ plugin: "vendor2.class",
+ devID: "device",
+ devices: []string{
+ "vendor2.com/class=device1",
+ "vendor2.com/class=device2",
+ },
+ },
+ {
+ plugin: "vendor3.class2",
+ devID: "device",
+ devices: []string{
+ "vendor3.com/class2=device1",
+ "vendor3.com/class2=device2",
+ },
+ },
+ },
+ invalid: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ annotations map[string]string
+ parsed []string
+ err error
+ )
+ for _, i := range tc.injections {
+ if tc.existing != nil {
+ annotations = tc.existing
+ }
+ annotations, err = UpdateAnnotations(annotations, i.plugin, i.devID, i.devices)
+ if !tc.invalid {
+ require.NoError(t, err, "CDI device injection annotation")
+ } else {
+ if err != nil {
+ break
+ }
+ }
+ }
+ if tc.invalid {
+ require.Error(t, err, "invalid injection")
+ } else {
+ require.Equal(t, tc.annotations, annotations)
+ _, parsed, err = ParseAnnotations(annotations)
+ require.NoError(t, err, "annotation parsing")
+ sort.Strings(tc.parsed)
+ sort.Strings(parsed)
+ require.Equal(t, tc.parsed, parsed, "parsed annotations")
+ }
+ })
+ }
+}
+
+func TestParseAnnotation(t *testing.T) {
+ type testCase = struct {
+ name string
+ annotations map[string]string
+ devices []string
+ invalid bool
+ }
+
+ for _, tc := range []*testCase{
+ {
+ name: "one vendor, one device",
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor.class_device": "vendor.com/class=device1",
+ },
+ devices: []string{
+ "vendor.com/class=device1",
+ },
+ },
+ {
+ name: "one vendor, multiple devices",
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor.class_device": "vendor.com/class=device1,vendor.com/class=device2,vendor.com/class=device3",
+ },
+ devices: []string{
+ "vendor.com/class=device1",
+ "vendor.com/class=device2",
+ "vendor.com/class=device3",
+ },
+ },
+
+ {
+ name: "one plugin, one device",
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor.class_device": "vendor.com/class=device",
+ },
+ devices: []string{
+ "vendor.com/class=device",
+ },
+ },
+ {
+ name: "one plugin, multiple devices",
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor.class_device": "vendor.com/class=device1,vendor.com/class=device2,vendor.com/class=device3",
+ },
+ devices: []string{
+ "vendor.com/class=device1",
+ "vendor.com/class=device2",
+ "vendor.com/class=device3",
+ },
+ },
+ {
+ name: "multiple plugins, multiple devices",
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor1.class_device": "vendor1.com/class=device1",
+ AnnotationPrefix + "vendor2.class_device": "vendor2.com/class=device1,vendor2.com/class=device2",
+ AnnotationPrefix + "vendor3.class2_device": "vendor3.com/class2=device1,vendor3.com/class2=device2,vendor3.com/class2=device3",
+ },
+ devices: []string{
+ "vendor1.com/class=device1",
+ "vendor2.com/class=device1",
+ "vendor2.com/class=device2",
+ "vendor3.com/class2=device1",
+ "vendor3.com/class2=device2",
+ "vendor3.com/class2=device3",
+ },
+ },
+ {
+ name: "invalid, malformed device reference",
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor1.class1_device": "vendor1.com/class1=device1",
+ AnnotationPrefix + "vendor2.class2_device": "vendor2.com/class2=device2",
+ AnnotationPrefix + "vendor3.class_device": "vendor3.com=device3",
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid, pre-resolved device",
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor1.class2_device": "vendor1.com/class2=device1,vendor1.com/class2=device2",
+ AnnotationPrefix + "vendor2.class_device": "/dev/null",
+ },
+ invalid: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ _, devices, err := ParseAnnotations(tc.annotations)
+ if !tc.invalid {
+ require.NoError(t, err, "parsing annotations")
+ sort.Strings(tc.devices)
+ sort.Strings(devices)
+ require.Equal(t, tc.devices, devices, "parsing annotations")
+ } else {
+ require.Error(t, err)
+ }
+ })
+ }
+}
diff --git a/pkg/cdi/cache.go b/pkg/cdi/cache.go
new file mode 100644
index 0000000..c807b55
--- /dev/null
+++ b/pkg/cdi/cache.go
@@ -0,0 +1,581 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/fsnotify/fsnotify"
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ "tags.cncf.io/container-device-interface/internal/multierror"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+// Option is an option to change some aspect of default CDI behavior.
+type Option func(*Cache) error
+
+// Cache stores CDI Specs loaded from Spec directories.
+type Cache struct {
+ sync.Mutex
+ specDirs []string
+ specs map[string][]*Spec
+ devices map[string]*Device
+ errors map[string][]error
+ dirErrors map[string]error
+
+ autoRefresh bool
+ watch *watch
+}
+
+// WithAutoRefresh returns an option to control automatic Cache refresh.
+// By default, auto-refresh is enabled, the list of Spec directories are
+// monitored and the Cache is automatically refreshed whenever a change
+// is detected. This option can be used to disable this behavior when a
+// manually refreshed mode is preferable.
+func WithAutoRefresh(autoRefresh bool) Option {
+ return func(c *Cache) error {
+ c.autoRefresh = autoRefresh
+ return nil
+ }
+}
+
+// NewCache creates a new CDI Cache. The cache is populated from a set
+// of CDI Spec directories. These can be specified using a WithSpecDirs
+// option. The default set of directories is exposed in DefaultSpecDirs.
+func NewCache(options ...Option) (*Cache, error) {
+ c := &Cache{
+ autoRefresh: true,
+ watch: &watch{},
+ }
+
+ WithSpecDirs(DefaultSpecDirs...)(c)
+ c.Lock()
+ defer c.Unlock()
+
+ return c, c.configure(options...)
+}
+
+// Configure applies options to the Cache. Updates and refreshes the
+// Cache if options have changed.
+func (c *Cache) Configure(options ...Option) error {
+ if len(options) == 0 {
+ return nil
+ }
+
+ c.Lock()
+ defer c.Unlock()
+
+ return c.configure(options...)
+}
+
+// Configure the Cache. Start/stop CDI Spec directory watch, refresh
+// the Cache if necessary.
+func (c *Cache) configure(options ...Option) error {
+ var err error
+
+ for _, o := range options {
+ if err = o(c); err != nil {
+ return fmt.Errorf("failed to apply cache options: %w", err)
+ }
+ }
+
+ c.dirErrors = make(map[string]error)
+
+ c.watch.stop()
+ if c.autoRefresh {
+ c.watch.setup(c.specDirs, c.dirErrors)
+ c.watch.start(&c.Mutex, c.refresh, c.dirErrors)
+ }
+ c.refresh()
+
+ return nil
+}
+
+// Refresh rescans the CDI Spec directories and refreshes the Cache.
+// In manual refresh mode the cache is always refreshed. In auto-
+// refresh mode the cache is only refreshed if it is out of date.
+func (c *Cache) Refresh() error {
+ c.Lock()
+ defer c.Unlock()
+
+ // force a refresh in manual mode
+ if refreshed, err := c.refreshIfRequired(!c.autoRefresh); refreshed {
+ return err
+ }
+
+ // collect and return cached errors, much like refresh() does it
+ var result error
+ for _, errors := range c.errors {
+ result = multierror.Append(result, errors...)
+ }
+ return result
+}
+
+// Refresh the Cache by rescanning CDI Spec directories and files.
+func (c *Cache) refresh() error {
+ var (
+ specs = map[string][]*Spec{}
+ devices = map[string]*Device{}
+ conflicts = map[string]struct{}{}
+ specErrors = map[string][]error{}
+ result []error
+ )
+
+ // collect errors per spec file path and once globally
+ collectError := func(err error, paths ...string) {
+ result = append(result, err)
+ for _, path := range paths {
+ specErrors[path] = append(specErrors[path], err)
+ }
+ }
+ // resolve conflicts based on device Spec priority (order of precedence)
+ resolveConflict := func(name string, dev *Device, old *Device) bool {
+ devSpec, oldSpec := dev.GetSpec(), old.GetSpec()
+ devPrio, oldPrio := devSpec.GetPriority(), oldSpec.GetPriority()
+ switch {
+ case devPrio > oldPrio:
+ return false
+ case devPrio == oldPrio:
+ devPath, oldPath := devSpec.GetPath(), oldSpec.GetPath()
+ collectError(fmt.Errorf("conflicting device %q (specs %q, %q)",
+ name, devPath, oldPath), devPath, oldPath)
+ conflicts[name] = struct{}{}
+ }
+ return true
+ }
+
+ _ = scanSpecDirs(c.specDirs, func(path string, priority int, spec *Spec, err error) error {
+ path = filepath.Clean(path)
+ if err != nil {
+ collectError(fmt.Errorf("failed to load CDI Spec %w", err), path)
+ return nil
+ }
+
+ vendor := spec.GetVendor()
+ specs[vendor] = append(specs[vendor], spec)
+
+ for _, dev := range spec.devices {
+ qualified := dev.GetQualifiedName()
+ other, ok := devices[qualified]
+ if ok {
+ if resolveConflict(qualified, dev, other) {
+ continue
+ }
+ }
+ devices[qualified] = dev
+ }
+
+ return nil
+ })
+
+ for conflict := range conflicts {
+ delete(devices, conflict)
+ }
+
+ c.specs = specs
+ c.devices = devices
+ c.errors = specErrors
+
+ return multierror.New(result...)
+}
+
+// RefreshIfRequired triggers a refresh if necessary.
+func (c *Cache) refreshIfRequired(force bool) (bool, error) {
+ // We need to refresh if
+ // - it's forced by an explicit call to Refresh() in manual mode
+ // - a missing Spec dir appears (added to watch) in auto-refresh mode
+ if force || (c.autoRefresh && c.watch.update(c.dirErrors)) {
+ return true, c.refresh()
+ }
+ return false, nil
+}
+
+// InjectDevices injects the given qualified devices to an OCI Spec. It
+// returns any unresolvable devices and an error if injection fails for
+// any of the devices.
+func (c *Cache) InjectDevices(ociSpec *oci.Spec, devices ...string) ([]string, error) {
+ var unresolved []string
+
+ if ociSpec == nil {
+ return devices, fmt.Errorf("can't inject devices, nil OCI Spec")
+ }
+
+ c.Lock()
+ defer c.Unlock()
+
+ c.refreshIfRequired(false)
+
+ edits := &ContainerEdits{}
+ specs := map[*Spec]struct{}{}
+
+ for _, device := range devices {
+ d := c.devices[device]
+ if d == nil {
+ unresolved = append(unresolved, device)
+ continue
+ }
+ if _, ok := specs[d.GetSpec()]; !ok {
+ specs[d.GetSpec()] = struct{}{}
+ edits.Append(d.GetSpec().edits())
+ }
+ edits.Append(d.edits())
+ }
+
+ if unresolved != nil {
+ return unresolved, fmt.Errorf("unresolvable CDI devices %s",
+ strings.Join(unresolved, ", "))
+ }
+
+ if err := edits.Apply(ociSpec); err != nil {
+ return nil, fmt.Errorf("failed to inject devices: %w", err)
+ }
+
+ return nil, nil
+}
+
+// highestPrioritySpecDir returns the Spec directory with highest priority
+// and its priority.
+func (c *Cache) highestPrioritySpecDir() (string, int) {
+ if len(c.specDirs) == 0 {
+ return "", -1
+ }
+
+ prio := len(c.specDirs) - 1
+ dir := c.specDirs[prio]
+
+ return dir, prio
+}
+
+// WriteSpec writes a Spec file with the given content into the highest
+// priority Spec directory. If name has a "json" or "yaml" extension it
+// choses the encoding. Otherwise the default YAML encoding is used.
+func (c *Cache) WriteSpec(raw *cdi.Spec, name string) error {
+ var (
+ specDir string
+ path string
+ prio int
+ spec *Spec
+ err error
+ )
+
+ specDir, prio = c.highestPrioritySpecDir()
+ if specDir == "" {
+ return errors.New("no Spec directories to write to")
+ }
+
+ path = filepath.Join(specDir, name)
+ if ext := filepath.Ext(path); ext != ".json" && ext != ".yaml" {
+ path += defaultSpecExt
+ }
+
+ spec, err = newSpec(raw, path, prio)
+ if err != nil {
+ return err
+ }
+
+ return spec.write(true)
+}
+
+// RemoveSpec removes a Spec with the given name from the highest
+// priority Spec directory. This function can be used to remove a
+// Spec previously written by WriteSpec(). If the file exists and
+// its removal fails RemoveSpec returns an error.
+func (c *Cache) RemoveSpec(name string) error {
+ var (
+ specDir string
+ path string
+ err error
+ )
+
+ specDir, _ = c.highestPrioritySpecDir()
+ if specDir == "" {
+ return errors.New("no Spec directories to remove from")
+ }
+
+ path = filepath.Join(specDir, name)
+ if ext := filepath.Ext(path); ext != ".json" && ext != ".yaml" {
+ path += defaultSpecExt
+ }
+
+ err = os.Remove(path)
+ if err != nil && errors.Is(err, fs.ErrNotExist) {
+ err = nil
+ }
+
+ return err
+}
+
+// GetDevice returns the cached device for the given qualified name.
+func (c *Cache) GetDevice(device string) *Device {
+ c.Lock()
+ defer c.Unlock()
+
+ c.refreshIfRequired(false)
+
+ return c.devices[device]
+}
+
+// ListDevices lists all cached devices by qualified name.
+func (c *Cache) ListDevices() []string {
+ var devices []string
+
+ c.Lock()
+ defer c.Unlock()
+
+ c.refreshIfRequired(false)
+
+ for name := range c.devices {
+ devices = append(devices, name)
+ }
+ sort.Strings(devices)
+
+ return devices
+}
+
+// ListVendors lists all vendors known to the cache.
+func (c *Cache) ListVendors() []string {
+ var vendors []string
+
+ c.Lock()
+ defer c.Unlock()
+
+ c.refreshIfRequired(false)
+
+ for vendor := range c.specs {
+ vendors = append(vendors, vendor)
+ }
+ sort.Strings(vendors)
+
+ return vendors
+}
+
+// ListClasses lists all device classes known to the cache.
+func (c *Cache) ListClasses() []string {
+ var (
+ cmap = map[string]struct{}{}
+ classes []string
+ )
+
+ c.Lock()
+ defer c.Unlock()
+
+ c.refreshIfRequired(false)
+
+ for _, specs := range c.specs {
+ for _, spec := range specs {
+ cmap[spec.GetClass()] = struct{}{}
+ }
+ }
+ for class := range cmap {
+ classes = append(classes, class)
+ }
+ sort.Strings(classes)
+
+ return classes
+}
+
+// GetVendorSpecs returns all specs for the given vendor.
+func (c *Cache) GetVendorSpecs(vendor string) []*Spec {
+ c.Lock()
+ defer c.Unlock()
+
+ c.refreshIfRequired(false)
+
+ return c.specs[vendor]
+}
+
+// GetSpecErrors returns all errors encountered for the spec during the
+// last cache refresh.
+func (c *Cache) GetSpecErrors(spec *Spec) []error {
+ var errors []error
+
+ c.Lock()
+ defer c.Unlock()
+
+ if errs, ok := c.errors[spec.GetPath()]; ok {
+ errors = make([]error, len(errs))
+ copy(errors, errs)
+ }
+
+ return errors
+}
+
+// GetErrors returns all errors encountered during the last
+// cache refresh.
+func (c *Cache) GetErrors() map[string][]error {
+ c.Lock()
+ defer c.Unlock()
+
+ errors := map[string][]error{}
+ for path, errs := range c.errors {
+ errors[path] = errs
+ }
+ for path, err := range c.dirErrors {
+ errors[path] = []error{err}
+ }
+
+ return errors
+}
+
+// GetSpecDirectories returns the CDI Spec directories currently in use.
+func (c *Cache) GetSpecDirectories() []string {
+ c.Lock()
+ defer c.Unlock()
+
+ dirs := make([]string, len(c.specDirs))
+ copy(dirs, c.specDirs)
+ return dirs
+}
+
+// GetSpecDirErrors returns any errors related to configured Spec directories.
+func (c *Cache) GetSpecDirErrors() map[string]error {
+ if c.dirErrors == nil {
+ return nil
+ }
+
+ c.Lock()
+ defer c.Unlock()
+
+ errors := make(map[string]error)
+ for dir, err := range c.dirErrors {
+ errors[dir] = err
+ }
+ return errors
+}
+
+// Our fsnotify helper wrapper.
+type watch struct {
+ watcher *fsnotify.Watcher
+ tracked map[string]bool
+}
+
+// Setup monitoring for the given Spec directories.
+func (w *watch) setup(dirs []string, dirErrors map[string]error) {
+ var (
+ dir string
+ err error
+ )
+ w.tracked = make(map[string]bool)
+ for _, dir = range dirs {
+ w.tracked[dir] = false
+ }
+
+ w.watcher, err = fsnotify.NewWatcher()
+ if err != nil {
+ for _, dir := range dirs {
+ dirErrors[dir] = fmt.Errorf("failed to create watcher: %w", err)
+ }
+ return
+ }
+
+ w.update(dirErrors)
+}
+
+// Start watching Spec directories for relevant changes.
+func (w *watch) start(m *sync.Mutex, refresh func() error, dirErrors map[string]error) {
+ go w.watch(w.watcher, m, refresh, dirErrors)
+}
+
+// Stop watching directories.
+func (w *watch) stop() {
+ if w.watcher == nil {
+ return
+ }
+
+ w.watcher.Close()
+ w.tracked = nil
+}
+
+// Watch Spec directory changes, triggering a refresh if necessary.
+func (w *watch) watch(fsw *fsnotify.Watcher, m *sync.Mutex, refresh func() error, dirErrors map[string]error) {
+ watch := fsw
+ if watch == nil {
+ return
+ }
+ for {
+ select {
+ case event, ok := <-watch.Events:
+ if !ok {
+ return
+ }
+
+ if (event.Op & (fsnotify.Rename | fsnotify.Remove | fsnotify.Write)) == 0 {
+ continue
+ }
+ if event.Op == fsnotify.Write {
+ if ext := filepath.Ext(event.Name); ext != ".json" && ext != ".yaml" {
+ continue
+ }
+ }
+
+ m.Lock()
+ if event.Op == fsnotify.Remove && w.tracked[event.Name] {
+ w.update(dirErrors, event.Name)
+ } else {
+ w.update(dirErrors)
+ }
+ refresh()
+ m.Unlock()
+
+ case _, ok := <-watch.Errors:
+ if !ok {
+ return
+ }
+ }
+ }
+}
+
+// Update watch with pending/missing or removed directories.
+func (w *watch) update(dirErrors map[string]error, removed ...string) bool {
+ var (
+ dir string
+ ok bool
+ err error
+ update bool
+ )
+
+ for dir, ok = range w.tracked {
+ if ok {
+ continue
+ }
+
+ err = w.watcher.Add(dir)
+ if err == nil {
+ w.tracked[dir] = true
+ delete(dirErrors, dir)
+ update = true
+ } else {
+ w.tracked[dir] = false
+ dirErrors[dir] = fmt.Errorf("failed to monitor for changes: %w", err)
+ }
+ }
+
+ for _, dir = range removed {
+ w.tracked[dir] = false
+ dirErrors[dir] = errors.New("directory removed")
+ update = true
+ }
+
+ return update
+}
diff --git a/pkg/cdi/cache_test.go b/pkg/cdi/cache_test.go
new file mode 100644
index 0000000..bc70d8c
--- /dev/null
+++ b/pkg/cdi/cache_test.go
@@ -0,0 +1,1883 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ "github.com/stretchr/testify/require"
+ "sigs.k8s.io/yaml"
+ "tags.cncf.io/container-device-interface/pkg/cdi/validate"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+func TestNewCache(t *testing.T) {
+ type testCase struct {
+ name string
+ etc map[string]string
+ run map[string]string
+ sources map[string]string
+ errors map[string]struct{}
+ dirErrors map[string]struct{}
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "no spec dirs",
+ dirErrors: map[string]struct{}{
+ "etc": {},
+ "run": {},
+ },
+ },
+ {
+ name: "no spec files",
+ etc: map[string]string{},
+ run: map[string]string{},
+ },
+ {
+ name: "one spec file",
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ sources: map[string]string{
+ "vendor1.com/device=dev1": "etc/vendor1.yaml",
+ },
+ },
+ {
+ name: "multiple spec files with override",
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ },
+ run: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ sources: map[string]string{
+ "vendor1.com/device=dev1": "run/vendor1.yaml",
+ "vendor1.com/device=dev2": "etc/vendor1.yaml",
+ },
+ },
+ {
+ name: "multiple spec files, with conflicts",
+ run: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor1-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ sources: map[string]string{
+ "vendor1.com/device=dev2": "run/vendor1.yaml",
+ },
+ errors: map[string]struct{}{
+ "run/vendor1.yaml": {},
+ "run/vendor1-other.yaml": {},
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ specDir string
+ err error
+ cache *Cache
+ )
+ if tc.etc != nil || tc.run != nil {
+ dir, err = createSpecDirs(t, tc.etc, tc.run)
+ if err != nil {
+ t.Errorf("failed to create test directory: %v", err)
+ return
+ }
+ } else {
+ dir, err = mkTestDir(t, nil)
+ }
+
+ cache, err = NewCache(WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run")),
+ )
+
+ if len(tc.dirErrors) != 0 {
+ for specDir = range tc.dirErrors {
+ specDir = filepath.Join(dir, specDir)
+ require.NotNil(t, cache.GetSpecDirErrors()[specDir])
+ return
+ }
+ }
+
+ if len(tc.errors) == 0 {
+ require.Nil(t, err)
+ }
+ require.NotNil(t, cache)
+
+ for name, dev := range cache.devices {
+ require.Equal(t, filepath.Join(dir, tc.sources[name]),
+ dev.GetSpec().GetPath())
+ }
+ for name, path := range tc.sources {
+ dev := cache.devices[name]
+ require.NotNil(t, dev)
+ require.Equal(t, filepath.Join(dir, path),
+ dev.GetSpec().GetPath())
+ }
+
+ for path := range tc.errors {
+ fullPath := filepath.Join(dir, path)
+ _, ok := cache.errors[fullPath]
+ require.True(t, ok)
+ }
+ for fullPath := range cache.errors {
+ path, err := filepath.Rel(dir, fullPath)
+ require.Nil(t, err)
+ _, ok := tc.errors[path]
+ require.True(t, ok)
+ }
+ })
+ }
+}
+
+func TestRefreshCache(t *testing.T) {
+ type specDirs struct {
+ etc map[string]string
+ run map[string]string
+ }
+ type testCase struct {
+ name string
+ updates []specDirs
+ errors []map[string]struct{}
+ devices [][]string
+ devprio []map[string]int
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "empty cache, add one Spec",
+ updates: []specDirs{
+ {},
+ {
+ run: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ },
+ devices: [][]string{
+ nil,
+ {
+ "vendor1.com/device=dev1",
+ },
+ },
+ devprio: []map[string]int{
+ {},
+ {
+ "vendor1.com/device=dev1": 1,
+ },
+ },
+ errors: []map[string]struct{}{
+ {},
+ {},
+ },
+ },
+ {
+ name: "one Spec, add another, no shadowing, no conflicts",
+ updates: []specDirs{
+ {
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ {
+ run: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ },
+ },
+ },
+ devices: [][]string{
+ {
+ "vendor1.com/device=dev1",
+ },
+ {
+ "vendor1.com/device=dev1",
+ "vendor1.com/device=dev2",
+ },
+ },
+ devprio: []map[string]int{
+ {
+ "vendor1.com/device=dev1": 0,
+ },
+ {
+ "vendor1.com/device=dev1": 0,
+ "vendor1.com/device=dev2": 1,
+ },
+ },
+ errors: []map[string]struct{}{
+ {},
+ {},
+ },
+ },
+ {
+ name: "two Specs, remove one",
+ updates: []specDirs{
+ {
+ run: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ "vendor1-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ },
+ },
+ {
+ run: map[string]string{
+ "vendor1.yaml": "remove",
+ },
+ },
+ },
+ devices: [][]string{
+ {
+ "vendor1.com/device=dev1",
+ "vendor1.com/device=dev2",
+ },
+ {
+ "vendor1.com/device=dev2",
+ },
+ },
+ devprio: []map[string]int{
+ {
+ "vendor1.com/device=dev1": 1,
+ "vendor1.com/device=dev2": 1,
+ },
+ {
+ "vendor1.com/device=dev2": 1,
+ },
+ },
+ errors: []map[string]struct{}{
+ {},
+ {},
+ },
+ },
+ {
+ name: "one Spec, add another, shadowing",
+ updates: []specDirs{
+ {
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ {
+ run: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ },
+ devices: [][]string{
+ {
+ "vendor1.com/device=dev1",
+ },
+ {
+ "vendor1.com/device=dev1",
+ },
+ },
+ devprio: []map[string]int{
+ {
+ "vendor1.com/device=dev1": 0,
+ },
+ {
+ "vendor1.com/device=dev1": 1,
+ },
+ },
+ errors: []map[string]struct{}{
+ {},
+ {},
+ },
+ },
+ {
+ name: "one Spec, add another, conflicts",
+ updates: []specDirs{
+ {
+ run: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ {
+ run: map[string]string{
+ "vendor1-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev3"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev3"
+ type: b
+ major: 10
+ minor: 3
+`,
+ },
+ },
+ },
+ devices: [][]string{
+ {
+ "vendor1.com/device=dev1",
+ "vendor1.com/device=dev2",
+ },
+ {
+ "vendor1.com/device=dev2",
+ "vendor1.com/device=dev3",
+ },
+ },
+ devprio: []map[string]int{
+ {
+ "vendor1.com/device=dev1": 1,
+ "vendor1.com/device=dev2": 1,
+ },
+ {
+ "vendor1.com/device=dev2": 1,
+ "vendor1.com/device=dev3": 1,
+ },
+ },
+ errors: []map[string]struct{}{
+ {},
+ {
+ "run/vendor1.yaml": {},
+ "run/vendor1-other.yaml": {},
+ },
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ err error
+ opts []Option
+ cache *Cache
+ )
+ for _, selfRefresh := range []bool{false, true} {
+ for idx, update := range tc.updates {
+ if idx == 0 {
+ dir, err = createSpecDirs(t, update.etc, update.run)
+ if err != nil {
+ t.Errorf("failed to create test directory: %v", err)
+ return
+ }
+ opts = []Option{
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ }
+ if !selfRefresh {
+ opts = append(opts, WithAutoRefresh(false))
+ }
+ cache, err = NewCache(opts...)
+ require.NotNil(t, cache)
+ } else {
+ err = updateSpecDirs(t, dir, update.etc, update.run)
+ if err != nil {
+ t.Errorf("failed to update test directory: %v", err)
+ return
+ }
+ if selfRefresh {
+ time.Sleep(100 * time.Millisecond)
+ } else {
+ err = cache.Refresh()
+
+ if len(tc.errors[idx]) == 0 {
+ require.Nil(t, err)
+ } else {
+ require.NotNil(t, err)
+ }
+ }
+ }
+
+ devices := cache.ListDevices()
+ if len(tc.devices[idx]) == 0 {
+ require.True(t, len(devices) == 0)
+ } else {
+ require.Equal(t, tc.devices[idx], devices)
+ }
+
+ for name, prio := range tc.devprio[idx] {
+ dev := cache.GetDevice(name)
+ require.NotNil(t, dev)
+ require.Equal(t, dev.GetSpec().GetPriority(), prio)
+ }
+
+ for _, v := range cache.ListVendors() {
+ for _, spec := range cache.GetVendorSpecs(v) {
+ err := cache.GetSpecErrors(spec)
+ relSpecPath, _ := filepath.Rel(dir, spec.GetPath())
+ _, ok := tc.errors[idx][relSpecPath]
+ require.True(t, (err == nil && !ok) || (err != nil && ok))
+ }
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestFuzzSelfRefreshCache(t *testing.T) {
+ type specDirs struct {
+ etc map[string]string
+ run map[string]string
+ }
+ type testCase struct {
+ name string
+ updates []specDirs
+ }
+
+ for _, tc := range []*testCase{
+ {
+ name: "one device in /etc, update it, then shadow it in /run",
+ updates: []specDirs{
+ {
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/original-vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ {
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/updated-vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ {
+ run: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/shadowed-vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ cache *Cache
+ err error
+ errCh chan error
+ stopCh chan struct{}
+
+ duration = 10 * time.Second
+ wg = &sync.WaitGroup{}
+ )
+
+ stopCh = make(chan struct{})
+ errCh = make(chan error, 2)
+
+ // injector: run injection loop until an error or request to stop
+ injector := func() {
+ var (
+ inject = "vendor1.com/device=dev1"
+ expect = map[string]struct{}{
+ "/dev/original-vendor1-dev1": {},
+ "/dev/updated-vendor1-dev1": {},
+ "/dev/shadowed-vendor1-dev1": {},
+ }
+ unresolved []string
+ err error
+ )
+
+ defer func() {
+ errCh <- err
+ wg.Done()
+ }()
+
+ for {
+ ociSpec := &oci.Spec{}
+ unresolved, err = cache.InjectDevices(ociSpec, inject)
+ if err != nil {
+ err = fmt.Errorf("device injection failed: %w", err)
+ return
+ }
+ if unresolved != nil {
+ err = fmt.Errorf("unresolved devices %s", strings.Join(unresolved, ","))
+ return
+ }
+
+ result := ociSpec.Linux.Devices[0].Path
+ if _, ok := expect[result]; !ok {
+ err = fmt.Errorf("unexpected device path %s", result)
+ return
+ }
+
+ select {
+ case _ = <-stopCh:
+ return
+ default:
+ }
+ }
+ }
+
+ // Run Spec update loop until an error or request to stop.
+ updater := func() {
+ var (
+ idx = 1
+ err error
+ )
+
+ defer func() {
+ errCh <- err
+ wg.Done()
+ }()
+
+ for {
+ if idx >= len(tc.updates) {
+ idx = 0
+ }
+ update := tc.updates[idx]
+ err = updateSpecDirs(t, dir, update.etc, update.run)
+ if err != nil {
+ return
+ }
+
+ select {
+ case _ = <-stopCh:
+ return
+ default:
+ }
+
+ idx++
+ }
+ }
+
+ fssyncer := func() {
+ var sync = time.NewTimer(2 * time.Second)
+
+ defer func() {
+ if !sync.Stop() {
+ <-sync.C
+ }
+ wg.Done()
+ }()
+
+ // run sync loop until request to stop (trying to amortize the fs-hit
+ // from updater()'s create+write+rename loop)
+ for {
+ select {
+ case _ = <-stopCh:
+ go osSync()
+ return
+ case _ = <-sync.C:
+ go osSync()
+ sync.Reset(2 * time.Second)
+ }
+ }
+ }
+
+ dir, err = createSpecDirs(t, tc.updates[0].etc, tc.updates[0].run)
+ require.NoError(t, err)
+
+ cache, err = NewCache(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ )
+ require.NoError(t, err)
+ require.NotNil(t, cache)
+
+ go injector()
+ go updater()
+ go fssyncer()
+ wg.Add(3)
+
+ done := time.After(duration)
+ for {
+ select {
+ case err = <-errCh:
+ require.NotNil(t, err)
+ case _ = <-done:
+ close(stopCh)
+ wg.Wait()
+ return
+ }
+ }
+ })
+ }
+}
+
+func TestInjectDevice(t *testing.T) {
+ type specDirs struct {
+ etc map[string]string
+ run map[string]string
+ }
+ type testCase struct {
+ name string
+ cdiSpecs specDirs
+ ociSpec *oci.Spec
+ devices []string
+ result *oci.Spec
+ unresolved []string
+ expectedErr error
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "empty OCI Spec, inject one device",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ ociSpec: &oci.Spec{},
+ devices: []string{
+ "vendor1.com/device=dev1",
+ },
+ result: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "VENDOR1_SPEC_VAR1=VAL1",
+ "VENDOR1_VAR1=VAL1",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/vendor1-dev1",
+ Type: "b",
+ Major: 10,
+ Minor: 1,
+ },
+ },
+ Resources: &oci.LinuxResources{
+ Devices: []oci.LinuxDeviceCgroup{
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(1),
+ Access: "rwm",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "non-empty OCI Spec, inject one device",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ ociSpec: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "ORIG_VAR1=VAL1",
+ "ORIG_VAR2=VAL2",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ },
+ {
+ Path: "/dev/zero",
+ },
+ },
+ },
+ },
+ devices: []string{
+ "vendor1.com/device=dev1",
+ },
+ result: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "ORIG_VAR1=VAL1",
+ "ORIG_VAR2=VAL2",
+ "VENDOR1_SPEC_VAR1=VAL1",
+ "VENDOR1_VAR1=VAL1",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ },
+ {
+ Path: "/dev/zero",
+ },
+ {
+ Path: "/dev/vendor1-dev1",
+ Type: "b",
+ Major: 10,
+ Minor: 1,
+ },
+ },
+ Resources: &oci.LinuxResources{
+ Devices: []oci.LinuxDeviceCgroup{
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(1),
+ Access: "rwm",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "non-empty OCI Spec, inject several devices, hooks",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+ hooks:
+ - hookName: prestart
+ path: "/usr/local/bin/prestart-vendor-hook"
+ args:
+ - "--verbose"
+ env:
+ - "HOOK_ENV1=PRESTART_VAL1"
+ - hookName: createRuntime
+ path: "/usr/local/bin/cr-vendor-hook"
+ args:
+ - "--debug"
+ env:
+ - "HOOK_ENV1=CREATE_RUNTIME_VAL1"
+ - name: "dev3"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV3=VAL3"
+ deviceNodes:
+ - path: "/dev/vendor1-dev3"
+ type: b
+ major: 10
+ minor: 3
+`,
+ },
+ },
+ ociSpec: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "ORIG_VAR1=VAL1",
+ "ORIG_VAR2=VAL2",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ },
+ {
+ Path: "/dev/zero",
+ },
+ },
+ },
+ },
+ devices: []string{
+ "vendor1.com/device=dev1",
+ "vendor1.com/device=dev2",
+ "vendor1.com/device=dev3",
+ },
+ result: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "ORIG_VAR1=VAL1",
+ "ORIG_VAR2=VAL2",
+ "VENDOR1_SPEC_VAR1=VAL1",
+ "VENDOR1_DEV1=VAL1",
+ "VENDOR1_DEV2=VAL2",
+ "VENDOR1_DEV3=VAL3",
+ },
+ },
+ Hooks: &oci.Hooks{
+ Prestart: []oci.Hook{
+ {
+ Path: "/usr/local/bin/prestart-vendor-hook",
+ Args: []string{"--verbose"},
+ Env: []string{"HOOK_ENV1=PRESTART_VAL1"},
+ },
+ },
+ CreateRuntime: []oci.Hook{
+ {
+ Path: "/usr/local/bin/cr-vendor-hook",
+ Args: []string{"--debug"},
+ Env: []string{"HOOK_ENV1=CREATE_RUNTIME_VAL1"},
+ },
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ },
+ {
+ Path: "/dev/zero",
+ },
+ {
+ Path: "/dev/vendor1-dev1",
+ Type: "b",
+ Major: 10,
+ Minor: 1,
+ },
+ {
+ Path: "/dev/vendor1-dev2",
+ Type: "b",
+ Major: 10,
+ Minor: 2,
+ },
+ {
+ Path: "/dev/vendor1-dev3",
+ Type: "b",
+ Major: 10,
+ Minor: 3,
+ },
+ },
+ Resources: &oci.LinuxResources{
+ Devices: []oci.LinuxDeviceCgroup{
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(1),
+ Access: "rwm",
+ },
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(2),
+ Access: "rwm",
+ },
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(3),
+ Access: "rwm",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "empty OCI Spec, non-existent device",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ ociSpec: &oci.Spec{},
+ devices: []string{
+ "vendor1.com/device=dev2",
+ },
+ result: &oci.Spec{},
+ unresolved: []string{
+ "vendor1.com/device=dev2",
+ },
+ expectedErr: errors.New("unresolvable CDI devices vendor1.com/device=dev2"),
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ err error
+ cache *Cache
+ )
+ dir, err = createSpecDirs(t, tc.cdiSpecs.etc, tc.cdiSpecs.run)
+ if err != nil {
+ t.Errorf("failed to create test directory: %v", err)
+ return
+ }
+ cache, err = NewCache(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ )
+ require.Nil(t, err)
+ require.NotNil(t, cache)
+
+ unresolved, err := cache.InjectDevices(tc.ociSpec, tc.devices...)
+ if len(tc.unresolved) != 0 {
+ require.NotNil(t, err)
+ require.Equal(t, tc.expectedErr, err)
+ require.Equal(t, tc.unresolved, unresolved)
+ return
+ }
+
+ require.Nil(t, err)
+ require.Equal(t, tc.result, tc.ociSpec)
+ })
+ }
+}
+
+func TestListVendorsAndClasses(t *testing.T) {
+ type specDirs struct {
+ etc map[string]string
+ run map[string]string
+ }
+ type testCase struct {
+ name string
+ cdiSpecs specDirs
+ vendors []string
+ classes []string
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "no vendors, no classes",
+ },
+ {
+ name: "one vendor, one class",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ vendors: []string{
+ "vendor1.com",
+ },
+ classes: []string{
+ "device",
+ },
+ },
+ {
+ name: "one vendor, multiple classes",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor1-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/other-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-other-dev1"
+ type: b
+ major: 11
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-other-dev2"
+ type: b
+ major: 11
+ minor: 2
+`,
+ },
+ },
+ vendors: []string{
+ "vendor1.com",
+ },
+ classes: []string{
+ "device",
+ "other-device",
+ },
+ },
+ {
+ name: "multiple vendor, multiple classes",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor2.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/other-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-dev1"
+ type: b
+ major: 12
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-dev2"
+ type: b
+ major: 12
+ minor: 2
+`,
+ "vendor2-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/another-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-another-dev1"
+ type: b
+ major: 13
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-another-dev2"
+ type: b
+ major: 13
+ minor: 2
+`,
+ "vendor3.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor3.com/yet-another-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor3-dev1"
+ type: b
+ major: 11
+ minor: 1
+
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor3-dev2"
+ type: b
+ major: 14
+ minor: 2
+`,
+ },
+ },
+ vendors: []string{
+ "vendor1.com",
+ "vendor2.com",
+ "vendor3.com",
+ },
+ classes: []string{
+ "another-device",
+ "device",
+ "other-device",
+ "yet-another-device",
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ err error
+ cache *Cache
+ )
+ dir, err = createSpecDirs(t, tc.cdiSpecs.etc, tc.cdiSpecs.run)
+ if err != nil {
+ t.Errorf("failed to create test directory: %v", err)
+ return
+ }
+ cache, err = NewCache(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ )
+ require.Nil(t, err)
+ require.NotNil(t, cache)
+
+ vendors := cache.ListVendors()
+ require.Equal(t, tc.vendors, vendors)
+ classes := cache.ListClasses()
+ require.Equal(t, tc.classes, classes)
+ })
+ }
+}
+
+func TestCacheWriteSpec(t *testing.T) {
+ type testCase struct {
+ name string
+ etc map[string]string
+ invalid map[string]bool
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "one spec file",
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ {
+ name: "multiple spec files",
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor2.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ "vendor3.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor3.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor3-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+
+ {
+ name: "multiple spec files/data, some invalid",
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.comdevice"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor2.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ "vendor3.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor3.com/device"
+containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor3-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ invalid: map[string]bool{
+ "vendor1.yaml": true,
+ "vendor3.yaml": true,
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ etc map[string]string
+ raw *cdi.Spec
+ err error
+ cache *Cache
+ other *Cache
+ )
+
+ SetSpecValidator(validate.WithNamedSchema("builtin"))
+
+ if len(tc.invalid) != 0 {
+ dir, err = createSpecDirs(t, nil, nil)
+ require.NoError(t, err)
+ cache, err = NewCache(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ WithAutoRefresh(false),
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, cache)
+
+ etc = map[string]string{}
+ for name, data := range tc.etc {
+ raw, err = ParseSpec([]byte(data))
+ require.NoError(t, err)
+ require.NotNil(t, raw)
+
+ err = cache.WriteSpec(raw, name)
+
+ if tc.invalid[name] {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ etc[name] = data
+ }
+ }
+ } else {
+ etc = tc.etc
+ }
+
+ dir, err = createSpecDirs(t, etc, nil)
+ require.NoError(t, err)
+
+ cache, err = NewCache(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ ),
+ )
+ require.NoError(t, err)
+ require.NotNil(t, cache)
+
+ other, err = NewCache(
+ WithSpecDirs(
+ filepath.Join(dir, "run"),
+ ),
+ WithAutoRefresh(false),
+ )
+ require.NoError(t, err)
+ require.NotNil(t, other)
+
+ cSpecs := map[string]*cdi.Spec{}
+ for _, vendor := range cache.ListVendors() {
+ for _, spec := range cache.GetVendorSpecs(vendor) {
+ name := filepath.Base(spec.GetPath())
+ cSpecs[name] = spec.Spec
+ err = other.WriteSpec(spec.Spec, name)
+ require.NoError(t, err)
+ }
+ }
+
+ err = other.Refresh()
+ require.NoError(t, err)
+
+ for _, vendor := range other.ListVendors() {
+ for _, spec := range other.GetVendorSpecs(vendor) {
+ name := filepath.Base(spec.GetPath())
+ require.Equal(t, spec.Spec, cSpecs[name])
+ }
+ }
+ })
+ }
+}
+
+func TestCacheTransientSpecs(t *testing.T) {
+ type testCase struct {
+ name string
+ specs []string
+ invalid map[int]bool
+ expected [][]string
+ numSpecFiles []int
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "invalid spec",
+ specs: []string{
+ `
+cdiVersion: "` + cdi.CurrentVersion + `"
+kind: "vendor.comdevice"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1`,
+ },
+ invalid: map[int]bool{
+ 0: true,
+ },
+ },
+ {
+ name: "add/remove one valid spec",
+ specs: []string{
+ `
+cdiVersion: "` + cdi.CurrentVersion + `"
+kind: "vendor.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ "-0",
+ },
+ expected: [][]string{
+ {
+ "vendor.com/device=dev1",
+ },
+ nil,
+ },
+ numSpecFiles: []int{
+ 1,
+ 0,
+ },
+ },
+ {
+ name: "add/remove multiple valid specs",
+ specs: []string{
+ `
+cdiVersion: "` + cdi.CurrentVersion + `"
+kind: "vendor.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ `
+cdiVersion: "` + cdi.CurrentVersion + `"
+kind: "vendor.com/device"
+devices:
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ `
+cdiVersion: "` + cdi.CurrentVersion + `"
+kind: "vendor.com/device"
+devices:
+ - name: "dev3"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor-dev3"
+ type: b
+ major: 10
+ minor: 3
+ - name: "dev4"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor-dev4"
+ type: b
+ major: 10
+ minor: 4
+`,
+ "-0",
+ "-1",
+ "-2",
+ },
+ expected: [][]string{
+ {
+ "vendor.com/device=dev1",
+ },
+ {
+ "vendor.com/device=dev1",
+ "vendor.com/device=dev2",
+ },
+ {
+ "vendor.com/device=dev1",
+ "vendor.com/device=dev2",
+ "vendor.com/device=dev3",
+ "vendor.com/device=dev4",
+ },
+ {
+ "vendor.com/device=dev2",
+ "vendor.com/device=dev3",
+ "vendor.com/device=dev4",
+ },
+ {
+ "vendor.com/device=dev3",
+ "vendor.com/device=dev4",
+ },
+ nil,
+ },
+ numSpecFiles: []int{
+ 1,
+ 2,
+ 3,
+ 2,
+ 1,
+ 0,
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ err error
+ cache *Cache
+ specFiles []os.DirEntry
+ specs = map[int]string{}
+ )
+
+ dir, err = createSpecDirs(t, nil, nil)
+ require.NoError(t, err)
+ cache, err = NewCache(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ WithAutoRefresh(false),
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, cache)
+
+ for idx, data := range tc.specs {
+ var (
+ transientID string
+ raw *cdi.Spec
+ delIdx int
+ err error
+ )
+
+ if data[0] == '-' {
+ delIdx, err = strconv.Atoi(string(data[1:]))
+ require.NoError(t, err)
+
+ err = cache.RemoveSpec(specs[delIdx])
+ require.NoError(t, err)
+ } else {
+ err = yaml.Unmarshal([]byte(data), &raw)
+ require.NoError(t, err)
+
+ transientID = fmt.Sprintf("id%d", idx)
+ specs[idx], err = GenerateNameForTransientSpec(raw, transientID)
+ if tc.invalid[idx] {
+ require.NotNil(t, err)
+ continue
+ }
+ require.NoError(t, err)
+
+ err = cache.WriteSpec(raw, specs[idx])
+ require.NoError(t, err)
+ }
+
+ err = cache.Refresh()
+ require.NoError(t, err)
+
+ devices := cache.ListDevices()
+ require.Equal(t, tc.expected[idx], devices)
+
+ specFiles, err = os.ReadDir(
+ filepath.Join(dir, "run"),
+ )
+ require.NoError(t, err)
+ require.Equal(t, tc.numSpecFiles[idx], len(specFiles))
+ }
+ })
+ }
+}
+
+// Create and populate automatically cleaned up spec directories.
+func createSpecDirs(t *testing.T, etc, run map[string]string) (string, error) {
+ return mkTestDir(t, map[string]map[string]string{
+ "etc": etc,
+ "run": run,
+ })
+}
+
+// Update spec directories with new data.
+func updateSpecDirs(t *testing.T, dir string, etc, run map[string]string) error {
+ updates := map[string]map[string]string{
+ "etc": {},
+ "run": {},
+ }
+ for sub, entries := range map[string]map[string]string{
+ "etc": etc,
+ "run": run,
+ } {
+ path := filepath.Join(dir, sub)
+ for name, data := range entries {
+ if data == "remove" {
+ os.Remove(filepath.Join(path, name))
+ } else {
+ updates[sub][name] = data
+ }
+ }
+ }
+ return updateTestDir(t, dir, updates)
+}
+
+func int64ptr(v int64) *int64 {
+ return &v
+}
diff --git a/pkg/cdi/cache_test_unix.go b/pkg/cdi/cache_test_unix.go
new file mode 100644
index 0000000..0ee5fb8
--- /dev/null
+++ b/pkg/cdi/cache_test_unix.go
@@ -0,0 +1,26 @@
+//go:build !windows
+// +build !windows
+
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import "syscall"
+
+func osSync() {
+ syscall.Sync()
+}
diff --git a/pkg/cdi/cache_test_windows.go b/pkg/cdi/cache_test_windows.go
new file mode 100644
index 0000000..c6dabf5
--- /dev/null
+++ b/pkg/cdi/cache_test_windows.go
@@ -0,0 +1,22 @@
+//go:build windows
+// +build windows
+
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+func osSync() {}
diff --git a/pkg/cdi/container-edits.go b/pkg/cdi/container-edits.go
new file mode 100644
index 0000000..688ddf7
--- /dev/null
+++ b/pkg/cdi/container-edits.go
@@ -0,0 +1,332 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ ocigen "github.com/opencontainers/runtime-tools/generate"
+ "tags.cncf.io/container-device-interface/specs-go"
+)
+
+const (
+ // PrestartHook is the name of the OCI "prestart" hook.
+ PrestartHook = "prestart"
+ // CreateRuntimeHook is the name of the OCI "createRuntime" hook.
+ CreateRuntimeHook = "createRuntime"
+ // CreateContainerHook is the name of the OCI "createContainer" hook.
+ CreateContainerHook = "createContainer"
+ // StartContainerHook is the name of the OCI "startContainer" hook.
+ StartContainerHook = "startContainer"
+ // PoststartHook is the name of the OCI "poststart" hook.
+ PoststartHook = "poststart"
+ // PoststopHook is the name of the OCI "poststop" hook.
+ PoststopHook = "poststop"
+)
+
+var (
+ // Names of recognized hooks.
+ validHookNames = map[string]struct{}{
+ PrestartHook: {},
+ CreateRuntimeHook: {},
+ CreateContainerHook: {},
+ StartContainerHook: {},
+ PoststartHook: {},
+ PoststopHook: {},
+ }
+)
+
+// ContainerEdits represent updates to be applied to an OCI Spec.
+// These updates can be specific to a CDI device, or they can be
+// specific to a CDI Spec. In the former case these edits should
+// be applied to all OCI Specs where the corresponding CDI device
+// is injected. In the latter case, these edits should be applied
+// to all OCI Specs where at least one devices from the CDI Spec
+// is injected.
+type ContainerEdits struct {
+ *specs.ContainerEdits
+}
+
+// Apply edits to the given OCI Spec. Updates the OCI Spec in place.
+// Returns an error if the update fails.
+func (e *ContainerEdits) Apply(spec *oci.Spec) error {
+ if spec == nil {
+ return errors.New("can't edit nil OCI Spec")
+ }
+ if e == nil || e.ContainerEdits == nil {
+ return nil
+ }
+
+ specgen := ocigen.NewFromSpec(spec)
+ if len(e.Env) > 0 {
+ specgen.AddMultipleProcessEnv(e.Env)
+ }
+
+ for _, d := range e.DeviceNodes {
+ dn := DeviceNode{d}
+
+ err := dn.fillMissingInfo()
+ if err != nil {
+ return err
+ }
+ dev := d.ToOCI()
+ if dev.UID == nil && spec.Process != nil {
+ if uid := spec.Process.User.UID; uid > 0 {
+ dev.UID = &uid
+ }
+ }
+ if dev.GID == nil && spec.Process != nil {
+ if gid := spec.Process.User.GID; gid > 0 {
+ dev.GID = &gid
+ }
+ }
+
+ specgen.RemoveDevice(dev.Path)
+ specgen.AddDevice(dev)
+
+ if dev.Type == "b" || dev.Type == "c" {
+ access := d.Permissions
+ if access == "" {
+ access = "rwm"
+ }
+ specgen.AddLinuxResourcesDevice(true, dev.Type, &dev.Major, &dev.Minor, access)
+ }
+ }
+
+ if len(e.Mounts) > 0 {
+ for _, m := range e.Mounts {
+ specgen.RemoveMount(m.ContainerPath)
+ specgen.AddMount(m.ToOCI())
+ }
+ sortMounts(&specgen)
+ }
+
+ for _, h := range e.Hooks {
+ switch h.HookName {
+ case PrestartHook:
+ specgen.AddPreStartHook(h.ToOCI())
+ case PoststartHook:
+ specgen.AddPostStartHook(h.ToOCI())
+ case PoststopHook:
+ specgen.AddPostStopHook(h.ToOCI())
+ // TODO: Maybe runtime-tools/generate should be updated with these...
+ case CreateRuntimeHook:
+ ensureOCIHooks(spec)
+ spec.Hooks.CreateRuntime = append(spec.Hooks.CreateRuntime, h.ToOCI())
+ case CreateContainerHook:
+ ensureOCIHooks(spec)
+ spec.Hooks.CreateContainer = append(spec.Hooks.CreateContainer, h.ToOCI())
+ case StartContainerHook:
+ ensureOCIHooks(spec)
+ spec.Hooks.StartContainer = append(spec.Hooks.StartContainer, h.ToOCI())
+ default:
+ return fmt.Errorf("unknown hook name %q", h.HookName)
+ }
+ }
+
+ return nil
+}
+
+// Validate container edits.
+func (e *ContainerEdits) Validate() error {
+ if e == nil || e.ContainerEdits == nil {
+ return nil
+ }
+
+ if err := ValidateEnv(e.Env); err != nil {
+ return fmt.Errorf("invalid container edits: %w", err)
+ }
+ for _, d := range e.DeviceNodes {
+ if err := (&DeviceNode{d}).Validate(); err != nil {
+ return err
+ }
+ }
+ for _, h := range e.Hooks {
+ if err := (&Hook{h}).Validate(); err != nil {
+ return err
+ }
+ }
+ for _, m := range e.Mounts {
+ if err := (&Mount{m}).Validate(); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// Append other edits into this one. If called with a nil receiver,
+// allocates and returns newly allocated edits.
+func (e *ContainerEdits) Append(o *ContainerEdits) *ContainerEdits {
+ if o == nil || o.ContainerEdits == nil {
+ return e
+ }
+ if e == nil {
+ e = &ContainerEdits{}
+ }
+ if e.ContainerEdits == nil {
+ e.ContainerEdits = &specs.ContainerEdits{}
+ }
+
+ e.Env = append(e.Env, o.Env...)
+ e.DeviceNodes = append(e.DeviceNodes, o.DeviceNodes...)
+ e.Hooks = append(e.Hooks, o.Hooks...)
+ e.Mounts = append(e.Mounts, o.Mounts...)
+
+ return e
+}
+
+// isEmpty returns true if these edits are empty. This is valid in a
+// global Spec context but invalid in a Device context.
+func (e *ContainerEdits) isEmpty() bool {
+ if e == nil {
+ return false
+ }
+ return len(e.Env)+len(e.DeviceNodes)+len(e.Hooks)+len(e.Mounts) == 0
+}
+
+// ValidateEnv validates the given environment variables.
+func ValidateEnv(env []string) error {
+ for _, v := range env {
+ if strings.IndexByte(v, byte('=')) <= 0 {
+ return fmt.Errorf("invalid environment variable %q", v)
+ }
+ }
+ return nil
+}
+
+// DeviceNode is a CDI Spec DeviceNode wrapper, used for validating DeviceNodes.
+type DeviceNode struct {
+ *specs.DeviceNode
+}
+
+// Validate a CDI Spec DeviceNode.
+func (d *DeviceNode) Validate() error {
+ validTypes := map[string]struct{}{
+ "": {},
+ "b": {},
+ "c": {},
+ "u": {},
+ "p": {},
+ }
+
+ if d.Path == "" {
+ return errors.New("invalid (empty) device path")
+ }
+ if _, ok := validTypes[d.Type]; !ok {
+ return fmt.Errorf("device %q: invalid type %q", d.Path, d.Type)
+ }
+ for _, bit := range d.Permissions {
+ if bit != 'r' && bit != 'w' && bit != 'm' {
+ return fmt.Errorf("device %q: invalid permissions %q",
+ d.Path, d.Permissions)
+ }
+ }
+ return nil
+}
+
+// Hook is a CDI Spec Hook wrapper, used for validating hooks.
+type Hook struct {
+ *specs.Hook
+}
+
+// Validate a hook.
+func (h *Hook) Validate() error {
+ if _, ok := validHookNames[h.HookName]; !ok {
+ return fmt.Errorf("invalid hook name %q", h.HookName)
+ }
+ if h.Path == "" {
+ return fmt.Errorf("invalid hook %q with empty path", h.HookName)
+ }
+ if err := ValidateEnv(h.Env); err != nil {
+ return fmt.Errorf("invalid hook %q: %w", h.HookName, err)
+ }
+ return nil
+}
+
+// Mount is a CDI Mount wrapper, used for validating mounts.
+type Mount struct {
+ *specs.Mount
+}
+
+// Validate a mount.
+func (m *Mount) Validate() error {
+ if m.HostPath == "" {
+ return errors.New("invalid mount, empty host path")
+ }
+ if m.ContainerPath == "" {
+ return errors.New("invalid mount, empty container path")
+ }
+ return nil
+}
+
+// Ensure OCI Spec hooks are not nil so we can add hooks.
+func ensureOCIHooks(spec *oci.Spec) {
+ if spec.Hooks == nil {
+ spec.Hooks = &oci.Hooks{}
+ }
+}
+
+// sortMounts sorts the mounts in the given OCI Spec.
+func sortMounts(specgen *ocigen.Generator) {
+ mounts := specgen.Mounts()
+ specgen.ClearMounts()
+ sort.Sort(orderedMounts(mounts))
+ specgen.Config.Mounts = mounts
+}
+
+// orderedMounts defines how to sort an OCI Spec Mount slice.
+// This is the almost the same implementation sa used by CRI-O and Docker,
+// with a minor tweak for stable sorting order (easier to test):
+//
+// https://github.com/moby/moby/blob/17.05.x/daemon/volumes.go#L26
+type orderedMounts []oci.Mount
+
+// Len returns the number of mounts. Used in sorting.
+func (m orderedMounts) Len() int {
+ return len(m)
+}
+
+// Less returns true if the number of parts (a/b/c would be 3 parts) in the
+// mount indexed by parameter 1 is less than that of the mount indexed by
+// parameter 2. Used in sorting.
+func (m orderedMounts) Less(i, j int) bool {
+ ip, jp := m.parts(i), m.parts(j)
+ if ip < jp {
+ return true
+ }
+ if jp < ip {
+ return false
+ }
+ return m[i].Destination < m[j].Destination
+}
+
+// Swap swaps two items in an array of mounts. Used in sorting
+func (m orderedMounts) Swap(i, j int) {
+ m[i], m[j] = m[j], m[i]
+}
+
+// parts returns the number of parts in the destination of a mount. Used in sorting.
+func (m orderedMounts) parts(i int) int {
+ return strings.Count(filepath.Clean(m[i].Destination), string(os.PathSeparator))
+}
diff --git a/pkg/cdi/container-edits_test.go b/pkg/cdi/container-edits_test.go
new file mode 100644
index 0000000..3bfc1df
--- /dev/null
+++ b/pkg/cdi/container-edits_test.go
@@ -0,0 +1,624 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "testing"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ "github.com/stretchr/testify/require"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+func TestValidateContainerEdits(t *testing.T) {
+ type testCase struct {
+ name string
+ edits *cdi.ContainerEdits
+ invalid bool
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "valid, empty edits",
+ edits: nil,
+ },
+ {
+ name: "valid, env var",
+ edits: &cdi.ContainerEdits{
+ Env: []string{"BAR=BARVALUE1"},
+ },
+ },
+ {
+ name: "invalid env, empty var",
+ edits: &cdi.ContainerEdits{
+ Env: []string{""},
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid env, no var name",
+ edits: &cdi.ContainerEdits{
+ Env: []string{"=foo"},
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid env, no assignment",
+ edits: &cdi.ContainerEdits{
+ Env: []string{"FOOBAR"},
+ },
+ invalid: true,
+ },
+ {
+ name: "valid device, path only",
+ edits: &cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/null",
+ },
+ },
+ },
+ },
+ {
+ name: "valid device, path+type",
+ edits: &cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/null",
+ Type: "c",
+ },
+ },
+ },
+ },
+ {
+ name: "valid device, path+type+permissions",
+ edits: &cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/null",
+ Type: "b",
+ Permissions: "rwm",
+ },
+ },
+ },
+ },
+ {
+ name: "invalid device, empty path",
+ edits: &cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid device, wrong type",
+ edits: &cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/vendorctl",
+ Type: "f",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid device, wrong permissions",
+ edits: &cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/vendorctl",
+ Type: "b",
+ Permissions: "to land",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "valid mount",
+ edits: &cdi.ContainerEdits{
+ Mounts: []*cdi.Mount{
+ {
+ HostPath: "/dev/vendorctl",
+ ContainerPath: "/dev/vendorctl",
+ },
+ },
+ },
+ },
+ {
+ name: "invalid mount, empty host path",
+ edits: &cdi.ContainerEdits{
+ Mounts: []*cdi.Mount{
+ {
+ HostPath: "",
+ ContainerPath: "/dev/vendorctl",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid mount, empty container path",
+ edits: &cdi.ContainerEdits{
+ Mounts: []*cdi.Mount{
+ {
+ HostPath: "/dev/vendorctl",
+ ContainerPath: "",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "valid hooks",
+ edits: &cdi.ContainerEdits{
+ Hooks: []*cdi.Hook{
+ {
+ HookName: "prestart",
+ Path: "/usr/local/bin/prestart-vendor-hook",
+ Args: []string{"--verbose"},
+ Env: []string{"VENDOR_ENV1=value1"},
+ },
+ {
+ HookName: "createRuntime",
+ Path: "/usr/local/bin/cr-vendor-hook",
+ Args: []string{"--debug"},
+ Env: []string{"VENDOR_ENV2=value2"},
+ },
+ {
+ HookName: "createContainer",
+ Path: "/usr/local/bin/cc-vendor-hook",
+ Args: []string{"--create"},
+ Env: []string{"VENDOR_ENV3=value3"},
+ },
+ {
+ HookName: "startContainer",
+ Path: "/usr/local/bin/sc-vendor-hook",
+ Args: []string{"--start"},
+ Env: []string{"VENDOR_ENV4=value4"},
+ },
+ {
+ HookName: "poststart",
+ Path: "/usr/local/bin/poststart-vendor-hook",
+ Env: []string{"VENDOR_ENV5=value5"},
+ },
+ {
+ HookName: "poststop",
+ Path: "/usr/local/bin/poststop-vendor-hook",
+ },
+ },
+ },
+ },
+ {
+ name: "invalid hook, empty path",
+ edits: &cdi.ContainerEdits{
+ Hooks: []*cdi.Hook{
+ {
+ HookName: "prestart",
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid hook, wrong hook name",
+ edits: &cdi.ContainerEdits{
+ Hooks: []*cdi.Hook{
+ {
+ HookName: "misCreateRuntime",
+ Path: "/usr/local/bin/cr-vendor-hook",
+ Args: []string{"--debug"},
+ Env: []string{"VENDOR_ENV2=value2"},
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid hook, wrong env",
+ edits: &cdi.ContainerEdits{
+ Hooks: []*cdi.Hook{
+ {
+ HookName: "poststart",
+ Path: "/usr/local/bin/cr-vendor-hook",
+ Args: []string{"--debug"},
+ Env: []string{"=value2"},
+ },
+ },
+ },
+ invalid: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ edits := ContainerEdits{tc.edits}
+ err := edits.Validate()
+ if tc.invalid {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestApplyContainerEdits(t *testing.T) {
+ type testCase struct {
+ name string
+ spec *oci.Spec
+ edits *cdi.ContainerEdits
+ result *oci.Spec
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "empty spec, empty edits",
+ spec: &oci.Spec{},
+ edits: nil,
+ result: &oci.Spec{},
+ },
+ {
+ name: "empty spec, env var",
+ spec: &oci.Spec{},
+ edits: &cdi.ContainerEdits{
+ Env: []string{"BAR=BARVALUE1"},
+ },
+ result: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "BAR=BARVALUE1",
+ },
+ },
+ },
+ },
+ {
+ name: "empty spec, device",
+ spec: &oci.Spec{},
+ edits: &cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/null",
+ },
+ },
+ },
+ result: &oci.Spec{
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ Type: "c",
+ Major: 1,
+ Minor: 3,
+ },
+ },
+ Resources: &oci.LinuxResources{
+ Devices: []oci.LinuxDeviceCgroup{
+ {
+ Allow: true,
+ Type: "c",
+ Major: int64ptr(1),
+ Minor: int64ptr(3),
+ Access: "rwm",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "empty spec, device, env var",
+ spec: &oci.Spec{},
+ edits: &cdi.ContainerEdits{
+ Env: []string{
+ "FOO=BAR",
+ },
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/null",
+ Type: "c",
+ },
+ },
+ },
+ result: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "FOO=BAR",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ Type: "c",
+ Major: 1,
+ Minor: 3,
+ },
+ },
+ Resources: &oci.LinuxResources{
+ Devices: []oci.LinuxDeviceCgroup{
+ {
+ Allow: true,
+ Type: "c",
+ Major: int64ptr(1),
+ Minor: int64ptr(3),
+ Access: "rwm",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "empty spec, mount",
+ spec: &oci.Spec{},
+ edits: &cdi.ContainerEdits{
+ Mounts: []*cdi.Mount{
+ {
+ HostPath: "/dev/host-vendorctl",
+ ContainerPath: "/dev/cntr-vendorctl",
+ },
+ },
+ },
+ result: &oci.Spec{
+ Mounts: []oci.Mount{
+ {
+ Source: "/dev/host-vendorctl",
+ Destination: "/dev/cntr-vendorctl",
+ },
+ },
+ },
+ },
+ {
+ name: "empty spec, hooks",
+ spec: &oci.Spec{},
+ edits: &cdi.ContainerEdits{
+ Hooks: []*cdi.Hook{
+ {
+ HookName: "prestart",
+ Path: "/usr/local/bin/prestart-vendor-hook",
+ Args: []string{"--verbose"},
+ Env: []string{"VENDOR_ENV1=value1"},
+ },
+ {
+ HookName: "createRuntime",
+ Path: "/usr/local/bin/cr-vendor-hook",
+ Args: []string{"--debug"},
+ Env: []string{"VENDOR_ENV2=value2"},
+ },
+ {
+ HookName: "createContainer",
+ Path: "/usr/local/bin/cc-vendor-hook",
+ Args: []string{"--create"},
+ Env: []string{"VENDOR_ENV3=value3"},
+ },
+ {
+ HookName: "startContainer",
+ Path: "/usr/local/bin/sc-vendor-hook",
+ Args: []string{"--start"},
+ Env: []string{"VENDOR_ENV4=value4"},
+ },
+ {
+ HookName: "poststart",
+ Path: "/usr/local/bin/poststart-vendor-hook",
+ Env: []string{"VENDOR_ENV5=value5"},
+ },
+ {
+ HookName: "poststop",
+ Path: "/usr/local/bin/poststop-vendor-hook",
+ },
+ },
+ },
+ result: &oci.Spec{
+ Hooks: &oci.Hooks{
+ Prestart: []oci.Hook{
+ {
+ Path: "/usr/local/bin/prestart-vendor-hook",
+ Args: []string{"--verbose"},
+ Env: []string{"VENDOR_ENV1=value1"},
+ },
+ },
+ CreateRuntime: []oci.Hook{
+ {
+ Path: "/usr/local/bin/cr-vendor-hook",
+ Args: []string{"--debug"},
+ Env: []string{"VENDOR_ENV2=value2"},
+ },
+ },
+ CreateContainer: []oci.Hook{
+ {
+ Path: "/usr/local/bin/cc-vendor-hook",
+ Args: []string{"--create"},
+ Env: []string{"VENDOR_ENV3=value3"},
+ },
+ },
+ StartContainer: []oci.Hook{
+ {
+ Path: "/usr/local/bin/sc-vendor-hook",
+ Args: []string{"--start"},
+ Env: []string{"VENDOR_ENV4=value4"},
+ },
+ },
+ Poststart: []oci.Hook{
+ {
+ Path: "/usr/local/bin/poststart-vendor-hook",
+ Env: []string{"VENDOR_ENV5=value5"},
+ },
+ },
+ Poststop: []oci.Hook{
+ {
+ Path: "/usr/local/bin/poststop-vendor-hook",
+ },
+ },
+ },
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ edits := ContainerEdits{tc.edits}
+ err := edits.Validate()
+ require.NoError(t, err)
+ err = edits.Apply(tc.spec)
+ require.NoError(t, err)
+ require.Equal(t, tc.result, tc.spec)
+ })
+ }
+}
+
+func TestAppend(t *testing.T) {
+ type testCase struct {
+ name string
+ dst *ContainerEdits
+ src []*ContainerEdits
+ result *ContainerEdits
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "merge nil into nil",
+ dst: nil,
+ src: []*ContainerEdits{
+ nil,
+ },
+ result: nil,
+ },
+ {
+ name: "merge non-nil into nil",
+ dst: nil,
+ src: []*ContainerEdits{
+ {
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var1=val1",
+ },
+ },
+ },
+ },
+ result: &ContainerEdits{
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var1=val1",
+ },
+ },
+ },
+ },
+ {
+ name: "merge nil into non-nil",
+ dst: &ContainerEdits{
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var1=val1",
+ },
+ },
+ },
+ src: []*ContainerEdits{
+ nil,
+ },
+ result: &ContainerEdits{
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var1=val1",
+ },
+ },
+ },
+ },
+ {
+ name: "merge multiple into non-nil",
+ dst: &ContainerEdits{
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var0=val0",
+ },
+ },
+ },
+ src: []*ContainerEdits{
+ {
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var1=val1",
+ },
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/dev1",
+ },
+ },
+ },
+ },
+ {
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var2=val2",
+ "var3=val3",
+ },
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/dev2",
+ },
+ {
+ Path: "/dev/dev3",
+ },
+ },
+ },
+ },
+ {
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var4=val4",
+ },
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/dev4",
+ },
+ },
+ },
+ },
+ },
+ result: &ContainerEdits{
+ ContainerEdits: &cdi.ContainerEdits{
+ Env: []string{
+ "var0=val0",
+ "var1=val1",
+ "var2=val2",
+ "var3=val3",
+ "var4=val4",
+ },
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ Path: "/dev/dev1",
+ },
+ {
+ Path: "/dev/dev2",
+ },
+ {
+ Path: "/dev/dev3",
+ },
+ {
+ Path: "/dev/dev4",
+ },
+ },
+ },
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dst := tc.dst
+ for _, src := range tc.src {
+ dst = dst.Append(src)
+ }
+ require.Equal(t, tc.result, dst, "append container edits")
+ })
+ }
+}
diff --git a/pkg/cdi/container-edits_unix.go b/pkg/cdi/container-edits_unix.go
new file mode 100644
index 0000000..59977b2
--- /dev/null
+++ b/pkg/cdi/container-edits_unix.go
@@ -0,0 +1,88 @@
+//go:build !windows
+// +build !windows
+
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "errors"
+ "fmt"
+
+ "golang.org/x/sys/unix"
+)
+
+const (
+ blockDevice = "b"
+ charDevice = "c" // or "u"
+ fifoDevice = "p"
+)
+
+// deviceInfoFromPath takes the path to a device and returns its type,
+// major and minor device numbers.
+//
+// It was adapted from https://github.com/opencontainers/runc/blob/v1.1.9/libcontainer/devices/device_unix.go#L30-L69
+func deviceInfoFromPath(path string) (devType string, major, minor int64, _ error) {
+ var stat unix.Stat_t
+ err := unix.Lstat(path, &stat)
+ if err != nil {
+ return "", 0, 0, err
+ }
+ switch stat.Mode & unix.S_IFMT {
+ case unix.S_IFBLK:
+ devType = blockDevice
+ case unix.S_IFCHR:
+ devType = charDevice
+ case unix.S_IFIFO:
+ devType = fifoDevice
+ default:
+ return "", 0, 0, errors.New("not a device node")
+ }
+ devNumber := uint64(stat.Rdev) //nolint:unconvert // Rdev is uint32 on e.g. MIPS.
+ return devType, int64(unix.Major(devNumber)), int64(unix.Minor(devNumber)), nil
+}
+
+// fillMissingInfo fills in missing mandatory attributes from the host device.
+func (d *DeviceNode) fillMissingInfo() error {
+ if d.HostPath == "" {
+ d.HostPath = d.Path
+ }
+
+ if d.Type != "" && (d.Major != 0 || d.Type == "p") {
+ return nil
+ }
+
+ deviceType, major, minor, err := deviceInfoFromPath(d.HostPath)
+ if err != nil {
+ return fmt.Errorf("failed to stat CDI host device %q: %w", d.HostPath, err)
+ }
+
+ if d.Type == "" {
+ d.Type = deviceType
+ } else {
+ if d.Type != deviceType {
+ return fmt.Errorf("CDI device (%q, %q), host type mismatch (%s, %s)",
+ d.Path, d.HostPath, d.Type, deviceType)
+ }
+ }
+ if d.Major == 0 && d.Type != "p" {
+ d.Major = major
+ d.Minor = minor
+ }
+
+ return nil
+}
diff --git a/pkg/cdi/container-edits_windows.go b/pkg/cdi/container-edits_windows.go
new file mode 100644
index 0000000..fd91afa
--- /dev/null
+++ b/pkg/cdi/container-edits_windows.go
@@ -0,0 +1,27 @@
+//go:build windows
+// +build windows
+
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import "fmt"
+
+// fillMissingInfo fills in missing mandatory attributes from the host device.
+func (d *DeviceNode) fillMissingInfo() error {
+ return fmt.Errorf("unimplemented")
+}
diff --git a/pkg/cdi/device.go b/pkg/cdi/device.go
new file mode 100644
index 0000000..00be48d
--- /dev/null
+++ b/pkg/cdi/device.go
@@ -0,0 +1,88 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "fmt"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ "tags.cncf.io/container-device-interface/internal/validation"
+ "tags.cncf.io/container-device-interface/pkg/parser"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+// Device represents a CDI device of a Spec.
+type Device struct {
+ *cdi.Device
+ spec *Spec
+}
+
+// Create a new Device, associate it with the given Spec.
+func newDevice(spec *Spec, d cdi.Device) (*Device, error) {
+ dev := &Device{
+ Device: &d,
+ spec: spec,
+ }
+
+ if err := dev.validate(); err != nil {
+ return nil, err
+ }
+
+ return dev, nil
+}
+
+// GetSpec returns the Spec this device is defined in.
+func (d *Device) GetSpec() *Spec {
+ return d.spec
+}
+
+// GetQualifiedName returns the qualified name for this device.
+func (d *Device) GetQualifiedName() string {
+ return parser.QualifiedName(d.spec.GetVendor(), d.spec.GetClass(), d.Name)
+}
+
+// ApplyEdits applies the device-speific container edits to an OCI Spec.
+func (d *Device) ApplyEdits(ociSpec *oci.Spec) error {
+ return d.edits().Apply(ociSpec)
+}
+
+// edits returns the applicable container edits for this spec.
+func (d *Device) edits() *ContainerEdits {
+ return &ContainerEdits{&d.ContainerEdits}
+}
+
+// Validate the device.
+func (d *Device) validate() error {
+ if err := ValidateDeviceName(d.Name); err != nil {
+ return err
+ }
+ name := d.Name
+ if d.spec != nil {
+ name = d.GetQualifiedName()
+ }
+ if err := validation.ValidateSpecAnnotations(name, d.Annotations); err != nil {
+ return err
+ }
+ edits := d.edits()
+ if edits.isEmpty() {
+ return fmt.Errorf("invalid device, empty device edits")
+ }
+ if err := edits.Validate(); err != nil {
+ return fmt.Errorf("invalid device %q: %w", d.Name, err)
+ }
+ return nil
+}
diff --git a/pkg/cdi/device_test.go b/pkg/cdi/device_test.go
new file mode 100644
index 0000000..07fb97c
--- /dev/null
+++ b/pkg/cdi/device_test.go
@@ -0,0 +1,78 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+func TestDeviceValidate(t *testing.T) {
+ type testCase struct {
+ name string
+ device *Device
+ invalid bool
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "valid name, valid edits",
+ device: &Device{
+ Device: &cdi.Device{
+ Name: "dev",
+ ContainerEdits: cdi.ContainerEdits{
+ Env: []string{"FOO=BAR"},
+ },
+ },
+ },
+ },
+ {
+ name: "valid name, invalid edits",
+ device: &Device{
+ Device: &cdi.Device{
+ Name: "dev",
+ ContainerEdits: cdi.ContainerEdits{
+ Env: []string{"=BAR"},
+ },
+ },
+ },
+ invalid: true,
+ },
+ {
+ name: "invalid name, valid edits",
+ device: &Device{
+ Device: &cdi.Device{
+ Name: "a dev ice",
+ ContainerEdits: cdi.ContainerEdits{
+ Env: []string{"FOO=BAR"},
+ },
+ },
+ },
+ invalid: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.device.validate()
+ if tc.invalid {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/pkg/cdi/doc.go b/pkg/cdi/doc.go
new file mode 100644
index 0000000..1897ef1
--- /dev/null
+++ b/pkg/cdi/doc.go
@@ -0,0 +1,276 @@
+// Package cdi has the primary purpose of providing an API for
+// interacting with CDI and consuming CDI devices.
+//
+// For more information about Container Device Interface, please refer to
+// https://tags.cncf.io/container-device-interface
+//
+// # Container Device Interface
+//
+// Container Device Interface, or CDI for short, provides comprehensive
+// third party device support for container runtimes. CDI uses vendor
+// provided specification files, CDI Specs for short, to describe how a
+// container's runtime environment should be modified when one or more
+// of the vendor-specific devices is injected into the container. Beyond
+// describing the low level platform-specific details of how to gain
+// basic access to a device, CDI Specs allow more fine-grained device
+// initialization, and the automatic injection of any necessary vendor-
+// or device-specific software that might be required for a container
+// to use a device or take full advantage of it.
+//
+// In the CDI device model containers request access to a device using
+// fully qualified device names, qualified names for short, consisting of
+// a vendor identifier, a device class and a device name or identifier.
+// These pieces of information together uniquely identify a device among
+// all device vendors, classes and device instances.
+//
+// This package implements an API for easy consumption of CDI. The API
+// implements discovery, loading and caching of CDI Specs and injection
+// of CDI devices into containers. This is the most common functionality
+// the vast majority of CDI consumers need. The API should be usable both
+// by OCI runtime clients and runtime implementations.
+//
+// # CDI Registry
+//
+// The primary interface to interact with CDI devices is the Registry. It
+// is essentially a cache of all Specs and devices discovered in standard
+// CDI directories on the host. The registry has two main functionality,
+// injecting devices into an OCI Spec and refreshing the cache of CDI
+// Specs and devices.
+//
+// # Device Injection
+//
+// Using the Registry one can inject CDI devices into a container with code
+// similar to the following snippet:
+//
+// import (
+// "fmt"
+// "strings"
+//
+// log "github.com/sirupsen/logrus"
+//
+// "tags.cncf.io/container-device-interface/pkg/cdi"
+// oci "github.com/opencontainers/runtime-spec/specs-go"
+// )
+//
+// func injectCDIDevices(spec *oci.Spec, devices []string) error {
+// log.Debug("pristine OCI Spec: %s", dumpSpec(spec))
+//
+// unresolved, err := cdi.GetRegistry().InjectDevices(spec, devices)
+// if err != nil {
+// return fmt.Errorf("CDI device injection failed: %w", err)
+// }
+//
+// log.Debug("CDI-updated OCI Spec: %s", dumpSpec(spec))
+// return nil
+// }
+//
+// # Cache Refresh
+//
+// By default the CDI Spec cache monitors the configured Spec directories
+// and automatically refreshes itself when necessary. This behavior can be
+// disabled using the WithAutoRefresh(false) option.
+//
+// Failure to set up monitoring for a Spec directory causes the directory to
+// get ignored and an error to be recorded among the Spec directory errors.
+// These errors can be queried using the GetSpecDirErrors() function. If the
+// error condition is transient, for instance a missing directory which later
+// gets created, the corresponding error will be removed once the condition
+// is over.
+//
+// With auto-refresh enabled injecting any CDI devices can be done without
+// an explicit call to Refresh(), using a code snippet similar to the
+// following:
+//
+// In a runtime implementation one typically wants to make sure the
+// CDI Spec cache is up to date before performing device injection.
+// A code snippet similar to the following accmplishes that:
+//
+// import (
+// "fmt"
+// "strings"
+//
+// log "github.com/sirupsen/logrus"
+//
+// "tags.cncf.io/container-device-interface/pkg/cdi"
+// oci "github.com/opencontainers/runtime-spec/specs-go"
+// )
+//
+// func injectCDIDevices(spec *oci.Spec, devices []string) error {
+// registry := cdi.GetRegistry()
+//
+// if err := registry.Refresh(); err != nil {
+// // Note:
+// // It is up to the implementation to decide whether
+// // to abort injection on errors. A failed Refresh()
+// // does not necessarily render the registry unusable.
+// // For instance, a parse error in a Spec file for
+// // vendor A does not have any effect on devices of
+// // vendor B...
+// log.Warnf("pre-injection Refresh() failed: %v", err)
+// }
+//
+// log.Debug("pristine OCI Spec: %s", dumpSpec(spec))
+//
+// unresolved, err := registry.InjectDevices(spec, devices)
+// if err != nil {
+// return fmt.Errorf("CDI device injection failed: %w", err)
+// }
+//
+// log.Debug("CDI-updated OCI Spec: %s", dumpSpec(spec))
+// return nil
+// }
+//
+// # Generated Spec Files, Multiple Directories, Device Precedence
+//
+// It is often necessary to generate Spec files dynamically. On some
+// systems the available or usable set of CDI devices might change
+// dynamically which then needs to be reflected in CDI Specs. For
+// some device classes it makes sense to enumerate the available
+// devices at every boot and generate Spec file entries for each
+// device found. Some CDI devices might need special client- or
+// request-specific configuration which can only be fulfilled by
+// dynamically generated client-specific entries in transient Spec
+// files.
+//
+// CDI can collect Spec files from multiple directories. Spec files are
+// automatically assigned priorities according to which directory they
+// were loaded from. The later a directory occurs in the list of CDI
+// directories to scan, the higher priority Spec files loaded from that
+// directory are assigned to. When two or more Spec files define the
+// same device, conflict is resolved by choosing the definition from the
+// Spec file with the highest priority.
+//
+// The default CDI directory configuration is chosen to encourage
+// separating dynamically generated CDI Spec files from static ones.
+// The default directories are '/etc/cdi' and '/var/run/cdi'. By putting
+// dynamically generated Spec files under '/var/run/cdi', those take
+// precedence over static ones in '/etc/cdi'. With this scheme, static
+// Spec files, typically installed by distro-specific packages, go into
+// '/etc/cdi' while all the dynamically generated Spec files, transient
+// or other, go into '/var/run/cdi'.
+//
+// # Spec File Generation
+//
+// CDI offers two functions for writing and removing dynamically generated
+// Specs from CDI Spec directories. These functions, WriteSpec() and
+// RemoveSpec() implicitly follow the principle of separating dynamic Specs
+// from the rest and therefore always write to and remove Specs from the
+// last configured directory.
+//
+// Corresponding functions are also provided for generating names for Spec
+// files. These functions follow a simple naming convention to ensure that
+// multiple entities generating Spec files simultaneously on the same host
+// do not end up using conflicting Spec file names. GenerateSpecName(),
+// GenerateNameForSpec(), GenerateTransientSpecName(), and
+// GenerateTransientNameForSpec() all generate names which can be passed
+// as such to WriteSpec() and subsequently to RemoveSpec().
+//
+// Generating a Spec file for a vendor/device class can be done with a
+// code snippet similar to the following:
+//
+// import (
+//
+// "fmt"
+// ...
+// "tags.cncf.io/container-device-interface/specs-go"
+// "tags.cncf.io/container-device-interface/pkg/cdi"
+//
+// )
+//
+// func generateDeviceSpecs() error {
+// registry := cdi.GetRegistry()
+// spec := &specs.Spec{
+// Version: specs.CurrentVersion,
+// Kind: vendor+"/"+class,
+// }
+//
+// for _, dev := range enumerateDevices() {
+// spec.Devices = append(spec.Devices, specs.Device{
+// Name: dev.Name,
+// ContainerEdits: getContainerEditsForDevice(dev),
+// })
+// }
+//
+// specName, err := cdi.GenerateNameForSpec(spec)
+// if err != nil {
+// return fmt.Errorf("failed to generate Spec name: %w", err)
+// }
+//
+// return registry.SpecDB().WriteSpec(spec, specName)
+// }
+//
+// Similarly, generating and later cleaning up transient Spec files can be
+// done with code fragments similar to the following. These transient Spec
+// files are temporary Spec files with container-specific parametrization.
+// They are typically created before the associated container is created
+// and removed once that container is removed.
+//
+// import (
+//
+// "fmt"
+// ...
+// "tags.cncf.io/container-device-interface/specs-go"
+// "tags.cncf.io/container-device-interface/pkg/cdi"
+//
+// )
+//
+// func generateTransientSpec(ctr Container) error {
+// registry := cdi.GetRegistry()
+// devices := getContainerDevs(ctr, vendor, class)
+// spec := &specs.Spec{
+// Version: specs.CurrentVersion,
+// Kind: vendor+"/"+class,
+// }
+//
+// for _, dev := range devices {
+// spec.Devices = append(spec.Devices, specs.Device{
+// // the generated name needs to be unique within the
+// // vendor/class domain on the host/node.
+// Name: generateUniqueDevName(dev, ctr),
+// ContainerEdits: getEditsForContainer(dev),
+// })
+// }
+//
+// // transientID is expected to guarantee that the Spec file name
+// // generated using <vendor, class, transientID> is unique within
+// // the host/node. If more than one device is allocated with the
+// // same vendor/class domain, either all generated Spec entries
+// // should go to a single Spec file (like in this sample snippet),
+// // or transientID should be unique for each generated Spec file.
+// transientID := getSomeSufficientlyUniqueIDForContainer(ctr)
+// specName, err := cdi.GenerateNameForTransientSpec(vendor, class, transientID)
+// if err != nil {
+// return fmt.Errorf("failed to generate Spec name: %w", err)
+// }
+//
+// return registry.SpecDB().WriteSpec(spec, specName)
+// }
+//
+// func removeTransientSpec(ctr Container) error {
+// registry := cdi.GetRegistry()
+// transientID := getSomeSufficientlyUniqueIDForContainer(ctr)
+// specName := cdi.GenerateNameForTransientSpec(vendor, class, transientID)
+//
+// return registry.SpecDB().RemoveSpec(specName)
+// }
+//
+// # CDI Spec Validation
+//
+// This package performs both syntactic and semantic validation of CDI
+// Spec file data when a Spec file is loaded via the registry or using
+// the ReadSpec API function. As part of the semantic verification, the
+// Spec file is verified against the CDI Spec JSON validation schema.
+//
+// If a valid externally provided JSON validation schema is found in
+// the filesystem at /etc/cdi/schema/schema.json it is loaded and used
+// as the default validation schema. If such a file is not found or
+// fails to load, an embedded no-op schema is used.
+//
+// The used validation schema can also be changed programmatically using
+// the SetSchema API convenience function. This function also accepts
+// the special "builtin" (BuiltinSchemaName) and "none" (NoneSchemaName)
+// schema names which switch the used schema to the in-repo validation
+// schema embedded into the binary or the now default no-op schema
+// correspondingly. Other names are interpreted as the path to the actual
+// validation schema to load and use.
+package cdi
diff --git a/pkg/cdi/qualified-device.go b/pkg/cdi/qualified-device.go
new file mode 100644
index 0000000..0bdfdc1
--- /dev/null
+++ b/pkg/cdi/qualified-device.go
@@ -0,0 +1,113 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "tags.cncf.io/container-device-interface/pkg/parser"
+)
+
+// QualifiedName returns the qualified name for a device.
+// The syntax for a qualified device names is
+//
+// "<vendor>/<class>=<name>".
+//
+// A valid vendor and class name may contain the following runes:
+//
+// 'A'-'Z', 'a'-'z', '0'-'9', '.', '-', '_'.
+//
+// A valid device name may contain the following runes:
+//
+// 'A'-'Z', 'a'-'z', '0'-'9', '-', '_', '.', ':'
+//
+// Deprecated: use parser.QualifiedName instead
+func QualifiedName(vendor, class, name string) string {
+ return parser.QualifiedName(vendor, class, name)
+}
+
+// IsQualifiedName tests if a device name is qualified.
+//
+// Deprecated: use parser.IsQualifiedName instead
+func IsQualifiedName(device string) bool {
+ return parser.IsQualifiedName(device)
+}
+
+// ParseQualifiedName splits a qualified name into device vendor, class,
+// and name. If the device fails to parse as a qualified name, or if any
+// of the split components fail to pass syntax validation, vendor and
+// class are returned as empty, together with the verbatim input as the
+// name and an error describing the reason for failure.
+//
+// Deprecated: use parser.ParseQualifiedName instead
+func ParseQualifiedName(device string) (string, string, string, error) {
+ return parser.ParseQualifiedName(device)
+}
+
+// ParseDevice tries to split a device name into vendor, class, and name.
+// If this fails, for instance in the case of unqualified device names,
+// ParseDevice returns an empty vendor and class together with name set
+// to the verbatim input.
+//
+// Deprecated: use parser.ParseDevice instead
+func ParseDevice(device string) (string, string, string) {
+ return parser.ParseDevice(device)
+}
+
+// ParseQualifier splits a device qualifier into vendor and class.
+// The syntax for a device qualifier is
+//
+// "<vendor>/<class>"
+//
+// If parsing fails, an empty vendor and the class set to the
+// verbatim input is returned.
+//
+// Deprecated: use parser.ParseQualifier instead
+func ParseQualifier(kind string) (string, string) {
+ return parser.ParseQualifier(kind)
+}
+
+// ValidateVendorName checks the validity of a vendor name.
+// A vendor name may contain the following ASCII characters:
+// - upper- and lowercase letters ('A'-'Z', 'a'-'z')
+// - digits ('0'-'9')
+// - underscore, dash, and dot ('_', '-', and '.')
+//
+// Deprecated: use parser.ValidateVendorName instead
+func ValidateVendorName(vendor string) error {
+ return parser.ValidateVendorName(vendor)
+}
+
+// ValidateClassName checks the validity of class name.
+// A class name may contain the following ASCII characters:
+// - upper- and lowercase letters ('A'-'Z', 'a'-'z')
+// - digits ('0'-'9')
+// - underscore, dash, and dot ('_', '-', and '.')
+//
+// Deprecated: use parser.ValidateClassName instead
+func ValidateClassName(class string) error {
+ return parser.ValidateClassName(class)
+}
+
+// ValidateDeviceName checks the validity of a device name.
+// A device name may contain the following ASCII characters:
+// - upper- and lowercase letters ('A'-'Z', 'a'-'z')
+// - digits ('0'-'9')
+// - underscore, dash, dot, colon ('_', '-', '.', ':')
+//
+// Deprecated: use parser.ValidateDeviceName instead
+func ValidateDeviceName(name string) error {
+ return parser.ValidateDeviceName(name)
+}
diff --git a/pkg/cdi/qualified-device_test.go b/pkg/cdi/qualified-device_test.go
new file mode 100644
index 0000000..c4f3f2f
--- /dev/null
+++ b/pkg/cdi/qualified-device_test.go
@@ -0,0 +1,153 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestQualifiedName(t *testing.T) {
+ type testCase = struct {
+ device string
+ vendor string
+ class string
+ name string
+ isQualified bool
+ isParsable bool
+ }
+
+ for _, tc := range []*testCase{
+ {
+ device: "vendor.com/class=dev",
+ vendor: "vendor.com",
+ class: "class",
+ name: "dev",
+ isQualified: true,
+ },
+ {
+ device: "vendor.com/class=0",
+ vendor: "vendor.com",
+ class: "class",
+ name: "0",
+ isQualified: true,
+ },
+ {
+ device: "vendor1.com/class1=dev1",
+ vendor: "vendor1.com",
+ class: "class1",
+ name: "dev1",
+ isQualified: true,
+ },
+ {
+ device: "vendor1.com/class.subclass=dev1",
+ vendor: "vendor1.com",
+ class: "class.subclass",
+ name: "dev1",
+ isQualified: true,
+ },
+ {
+ device: "other-vendor1.com/class_1=dev_1",
+ vendor: "other-vendor1.com",
+ class: "class_1",
+ name: "dev_1",
+ isQualified: true,
+ },
+ {
+ device: "yet_another-vendor2.com/c-lass_2=dev_1:2.3",
+ vendor: "yet_another-vendor2.com",
+ class: "c-lass_2",
+ name: "dev_1:2.3",
+ isQualified: true,
+ },
+ {
+ device: "_invalid.com/class=dev",
+ vendor: "_invalid.com",
+ class: "class",
+ name: "dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid2.com-/class=dev",
+ vendor: "invalid2.com-",
+ class: "class",
+ name: "dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid3.com/_class=dev",
+ vendor: "invalid3.com",
+ class: "_class",
+ name: "dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid4.com/class_=dev",
+ vendor: "invalid4.com",
+ class: "class_",
+ name: "dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid5.com/class=-dev",
+ vendor: "invalid5.com",
+ class: "class",
+ name: "-dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid6.com/class=dev:",
+ vendor: "invalid6.com",
+ class: "class",
+ name: "dev:",
+ isParsable: true,
+ },
+ {
+ device: "*.com/*dev=*gpu*",
+ vendor: "*.com",
+ class: "*dev",
+ name: "*gpu*",
+ isParsable: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ vendor, class, name, err := ParseQualifiedName(tc.device)
+ if tc.isQualified {
+ require.True(t, IsQualifiedName(tc.device), "qualified name %q", tc.device)
+ require.NoError(t, err)
+ require.Equal(t, tc.vendor, vendor, "qualified name %q", tc.device)
+ require.Equal(t, tc.class, class, "qualified name %q", tc.device)
+ require.Equal(t, tc.name, name, "qualified name %q", tc.device)
+
+ vendor, class, name = ParseDevice(tc.device)
+ require.Equal(t, tc.vendor, vendor, "parsed name %q", tc.device)
+ require.Equal(t, tc.class, class, "parse name %q", tc.device)
+ require.Equal(t, tc.name, name, "parsed name %q", tc.device)
+
+ device := QualifiedName(vendor, class, name)
+ require.Equal(t, tc.device, device, "constructed device %q", tc.device)
+ } else if tc.isParsable {
+ require.False(t, IsQualifiedName(tc.device), "parsed name %q", tc.device)
+ vendor, class, name = ParseDevice(tc.device)
+ require.Equal(t, tc.vendor, vendor, "parsed name %q", tc.device)
+ require.Equal(t, tc.class, class, "parse name %q", tc.device)
+ require.Equal(t, tc.name, name, "parsed name %q", tc.device)
+ }
+ })
+ }
+}
diff --git a/pkg/cdi/registry.go b/pkg/cdi/registry.go
new file mode 100644
index 0000000..7f12c77
--- /dev/null
+++ b/pkg/cdi/registry.go
@@ -0,0 +1,150 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "sync"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+// Registry keeps a cache of all CDI Specs installed or generated on
+// the host. Registry is the primary interface clients should use to
+// interact with CDI.
+//
+// The most commonly used Registry functions are for refreshing the
+// registry and injecting CDI devices into an OCI Spec.
+type Registry interface {
+ RegistryResolver
+ RegistryRefresher
+ DeviceDB() RegistryDeviceDB
+ SpecDB() RegistrySpecDB
+}
+
+// RegistryRefresher is the registry interface for refreshing the
+// cache of CDI Specs and devices.
+//
+// Configure reconfigures the registry with the given options.
+//
+// Refresh rescans all CDI Spec directories and updates the
+// state of the cache to reflect any changes. It returns any
+// errors encountered during the refresh.
+//
+// GetErrors returns all errors encountered for any of the scanned
+// Spec files during the last cache refresh.
+//
+// GetSpecDirectories returns the set up CDI Spec directories
+// currently in use. The directories are returned in the scan
+// order of Refresh().
+//
+// GetSpecDirErrors returns any errors related to the configured
+// Spec directories.
+type RegistryRefresher interface {
+ Configure(...Option) error
+ Refresh() error
+ GetErrors() map[string][]error
+ GetSpecDirectories() []string
+ GetSpecDirErrors() map[string]error
+}
+
+// RegistryResolver is the registry interface for injecting CDI
+// devices into an OCI Spec.
+//
+// InjectDevices takes an OCI Spec and injects into it a set of
+// CDI devices given by qualified name. It returns the names of
+// any unresolved devices and an error if injection fails.
+type RegistryResolver interface {
+ InjectDevices(spec *oci.Spec, device ...string) (unresolved []string, err error)
+}
+
+// RegistryDeviceDB is the registry interface for querying devices.
+//
+// GetDevice returns the CDI device for the given qualified name. If
+// the device is not GetDevice returns nil.
+//
+// ListDevices returns a slice with the names of qualified device
+// known. The returned slice is sorted.
+type RegistryDeviceDB interface {
+ GetDevice(device string) *Device
+ ListDevices() []string
+}
+
+// RegistrySpecDB is the registry interface for querying CDI Specs.
+//
+// ListVendors returns a slice with all vendors known. The returned
+// slice is sorted.
+//
+// ListClasses returns a slice with all classes known. The returned
+// slice is sorted.
+//
+// GetVendorSpecs returns a slice of all Specs for the vendor.
+//
+// GetSpecErrors returns any errors for the Spec encountered during
+// the last cache refresh.
+//
+// WriteSpec writes the Spec with the given content and name to the
+// last Spec directory.
+type RegistrySpecDB interface {
+ ListVendors() []string
+ ListClasses() []string
+ GetVendorSpecs(vendor string) []*Spec
+ GetSpecErrors(*Spec) []error
+ WriteSpec(raw *cdi.Spec, name string) error
+ RemoveSpec(name string) error
+}
+
+type registry struct {
+ *Cache
+}
+
+var _ Registry = &registry{}
+
+var (
+ reg *registry
+ initOnce sync.Once
+)
+
+// GetRegistry returns the CDI registry. If any options are given, those
+// are applied to the registry.
+func GetRegistry(options ...Option) Registry {
+ var new bool
+ initOnce.Do(func() {
+ reg, _ = getRegistry(options...)
+ new = true
+ })
+ if !new && len(options) > 0 {
+ reg.Configure(options...)
+ reg.Refresh()
+ }
+ return reg
+}
+
+// DeviceDB returns the registry interface for querying devices.
+func (r *registry) DeviceDB() RegistryDeviceDB {
+ return r
+}
+
+// SpecDB returns the registry interface for querying Specs.
+func (r *registry) SpecDB() RegistrySpecDB {
+ return r
+}
+
+func getRegistry(options ...Option) (*registry, error) {
+ c, err := NewCache(options...)
+ return &registry{c}, err
+}
diff --git a/pkg/cdi/registry_test.go b/pkg/cdi/registry_test.go
new file mode 100644
index 0000000..8dd89bb
--- /dev/null
+++ b/pkg/cdi/registry_test.go
@@ -0,0 +1,823 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "path/filepath"
+ "testing"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegistryResolver(t *testing.T) {
+ type specDirs struct {
+ etc map[string]string
+ run map[string]string
+ }
+ type testCase struct {
+ name string
+ cdiSpecs specDirs
+ ociSpec *oci.Spec
+ devices []string
+ result *oci.Spec
+ unresolved []string
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "empty OCI Spec, inject one device",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ ociSpec: &oci.Spec{},
+ devices: []string{
+ "vendor1.com/device=dev1",
+ },
+ result: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "VENDOR1_SPEC_VAR1=VAL1",
+ "VENDOR1_VAR1=VAL1",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/vendor1-dev1",
+ Type: "b",
+ Major: 10,
+ Minor: 1,
+ },
+ },
+ Resources: &oci.LinuxResources{
+ Devices: []oci.LinuxDeviceCgroup{
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(1),
+ Access: "rwm",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "non-empty OCI Spec, inject one device",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ ociSpec: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "ORIG_VAR1=VAL1",
+ "ORIG_VAR2=VAL2",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ },
+ {
+ Path: "/dev/zero",
+ },
+ },
+ },
+ },
+ devices: []string{
+ "vendor1.com/device=dev1",
+ },
+ result: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "ORIG_VAR1=VAL1",
+ "ORIG_VAR2=VAL2",
+ "VENDOR1_SPEC_VAR1=VAL1",
+ "VENDOR1_VAR1=VAL1",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ },
+ {
+ Path: "/dev/zero",
+ },
+ {
+ Path: "/dev/vendor1-dev1",
+ Type: "b",
+ Major: 10,
+ Minor: 1,
+ },
+ },
+ Resources: &oci.LinuxResources{
+ Devices: []oci.LinuxDeviceCgroup{
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(1),
+ Access: "rwm",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "non-empty OCI Spec, inject several devices, hooks",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+ hooks:
+ - hookName: prestart
+ path: "/usr/local/bin/prestart-vendor-hook"
+ args:
+ - "--verbose"
+ env:
+ - "HOOK_ENV1=PRESTART_VAL1"
+ - hookName: createRuntime
+ path: "/usr/local/bin/cr-vendor-hook"
+ args:
+ - "--debug"
+ env:
+ - "HOOK_ENV1=CREATE_RUNTIME_VAL1"
+ - name: "dev3"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV3=VAL3"
+ deviceNodes:
+ - path: "/dev/vendor1-dev3"
+ type: b
+ major: 10
+ minor: 3
+`,
+ },
+ },
+ ociSpec: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "ORIG_VAR1=VAL1",
+ "ORIG_VAR2=VAL2",
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ },
+ {
+ Path: "/dev/zero",
+ },
+ },
+ },
+ },
+ devices: []string{
+ "vendor1.com/device=dev1",
+ "vendor1.com/device=dev2",
+ "vendor1.com/device=dev3",
+ },
+ result: &oci.Spec{
+ Process: &oci.Process{
+ Env: []string{
+ "ORIG_VAR1=VAL1",
+ "ORIG_VAR2=VAL2",
+ "VENDOR1_SPEC_VAR1=VAL1",
+ "VENDOR1_DEV1=VAL1",
+ "VENDOR1_DEV2=VAL2",
+ "VENDOR1_DEV3=VAL3",
+ },
+ },
+ Hooks: &oci.Hooks{
+ Prestart: []oci.Hook{
+ {
+ Path: "/usr/local/bin/prestart-vendor-hook",
+ Args: []string{"--verbose"},
+ Env: []string{"HOOK_ENV1=PRESTART_VAL1"},
+ },
+ },
+ CreateRuntime: []oci.Hook{
+ {
+ Path: "/usr/local/bin/cr-vendor-hook",
+ Args: []string{"--debug"},
+ Env: []string{"HOOK_ENV1=CREATE_RUNTIME_VAL1"},
+ },
+ },
+ },
+ Linux: &oci.Linux{
+ Devices: []oci.LinuxDevice{
+ {
+ Path: "/dev/null",
+ },
+ {
+ Path: "/dev/zero",
+ },
+ {
+ Path: "/dev/vendor1-dev1",
+ Type: "b",
+ Major: 10,
+ Minor: 1,
+ },
+ {
+ Path: "/dev/vendor1-dev2",
+ Type: "b",
+ Major: 10,
+ Minor: 2,
+ },
+ {
+ Path: "/dev/vendor1-dev3",
+ Type: "b",
+ Major: 10,
+ Minor: 3,
+ },
+ },
+ Resources: &oci.LinuxResources{
+ Devices: []oci.LinuxDeviceCgroup{
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(1),
+ Access: "rwm",
+ },
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(2),
+ Access: "rwm",
+ },
+ {
+ Allow: true,
+ Type: "b",
+ Major: int64ptr(10),
+ Minor: int64ptr(3),
+ Access: "rwm",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "empty OCI Spec, non-existent device",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ ociSpec: &oci.Spec{},
+ devices: []string{
+ "vendor1.com/device=dev2",
+ },
+ result: &oci.Spec{},
+ unresolved: []string{
+ "vendor1.com/device=dev2",
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ err error
+ reg Registry
+ )
+ dir, err = createSpecDirs(t, tc.cdiSpecs.etc, tc.cdiSpecs.run)
+ if err != nil {
+ t.Errorf("failed to create test directory: %v", err)
+ return
+ }
+ reg = GetRegistry(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ )
+ require.Nil(t, err)
+ require.NotNil(t, reg)
+
+ unresolved, err := reg.InjectDevices(tc.ociSpec, tc.devices...)
+ if len(tc.unresolved) != 0 {
+ require.NotNil(t, err)
+ require.Equal(t, tc.unresolved, unresolved)
+ return
+ }
+
+ require.Nil(t, err)
+ require.Equal(t, tc.result, tc.ociSpec)
+ })
+ }
+}
+
+func TestRegistrySpecDB(t *testing.T) {
+ type specDirs struct {
+ etc map[string]string
+ run map[string]string
+ }
+ type testCase struct {
+ name string
+ cdiSpecs specDirs
+ vendors []string
+ classes []string
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "no vendors, no classes",
+ },
+ {
+ name: "one vendor, one class",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ vendors: []string{
+ "vendor1.com",
+ },
+ classes: []string{
+ "device",
+ },
+ },
+ {
+ name: "one vendor, multiple classes",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor1-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/other-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-other-dev1"
+ type: b
+ major: 12
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-other-dev2"
+ type: b
+ major: 11
+ minor: 2
+`,
+ },
+ },
+ vendors: []string{
+ "vendor1.com",
+ },
+ classes: []string{
+ "device",
+ "other-device",
+ },
+ },
+ {
+ name: "multiple vendor, multiple classes",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor2.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/other-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-dev1"
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-dev2"
+`,
+ "vendor2-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/another-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-another-dev1"
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-another-dev2"
+`,
+ "vendor3.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor3.com/yet-another-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor3-dev1"
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor3-dev2"
+`,
+ },
+ },
+ vendors: []string{
+ "vendor1.com",
+ "vendor2.com",
+ "vendor3.com",
+ },
+ classes: []string{
+ "another-device",
+ "device",
+ "other-device",
+ "yet-another-device",
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ err error
+ reg Registry
+ )
+ dir, err = createSpecDirs(t, tc.cdiSpecs.etc, tc.cdiSpecs.run)
+ if err != nil {
+ t.Errorf("failed to create test directory: %v", err)
+ return
+ }
+ reg = GetRegistry(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ )
+ require.Nil(t, err)
+ require.NotNil(t, reg)
+
+ vendors := reg.SpecDB().ListVendors()
+ require.Equal(t, tc.vendors, vendors)
+ classes := reg.SpecDB().ListClasses()
+ require.Equal(t, tc.classes, classes)
+ })
+ }
+}
+
+func TestRegistryDeviceDB(t *testing.T) {
+ type specDirs struct {
+ etc map[string]string
+ run map[string]string
+ }
+ type testCase struct {
+ name string
+ cdiSpecs specDirs
+ devices []string
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "no vendors, no classes",
+ },
+ {
+ name: "one vendor, one class",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+ env:
+ - VENDOR1_SPEC_VAR1=VAL1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "VENDOR1_VAR1=VAL1"
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+`,
+ },
+ },
+ devices: []string{
+ "vendor1.com/device=dev1",
+ },
+ },
+ {
+ name: "one vendor, multiple classes",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor1-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/other-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-other-dev1"
+ type: b
+ major: 11
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-other-dev2"
+ type: b
+ major: 11
+ minor: 2
+`,
+ },
+ },
+ devices: []string{
+ "vendor1.com/device=dev1",
+ "vendor1.com/device=dev2",
+ "vendor1.com/other-device=dev1",
+ "vendor1.com/other-device=dev2",
+ },
+ },
+ {
+ name: "multiple vendor, multiple classes",
+ cdiSpecs: specDirs{
+ etc: map[string]string{
+ "vendor1.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor1-dev1"
+ type: b
+ major: 10
+ minor: 1
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "VENDOR1_DEV2=VAL2"
+ deviceNodes:
+ - path: "/dev/vendor1-dev2"
+ type: b
+ major: 10
+ minor: 2
+`,
+ "vendor2.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/other-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-dev1"
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-dev2"
+`,
+ "vendor2-other.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/another-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-another-dev1"
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor2-another-dev2"
+`,
+ "vendor3.yaml": `
+cdiVersion: "0.3.0"
+kind: "vendor3.com/yet-another-device"
+containerEdits:
+devices:
+ - name: "dev1"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor3-dev1"
+ - name: "dev2"
+ containerEdits:
+ deviceNodes:
+ - path: "/dev/vendor3-dev2"
+`,
+ },
+ },
+ devices: []string{
+ "vendor1.com/device=dev1",
+ "vendor1.com/device=dev2",
+ "vendor2.com/another-device=dev1",
+ "vendor2.com/another-device=dev2",
+ "vendor2.com/other-device=dev1",
+ "vendor2.com/other-device=dev2",
+ "vendor3.com/yet-another-device=dev1",
+ "vendor3.com/yet-another-device=dev2",
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ err error
+ reg Registry
+ )
+ dir, err = createSpecDirs(t, tc.cdiSpecs.etc, tc.cdiSpecs.run)
+ if err != nil {
+ t.Errorf("failed to create test directory: %v", err)
+ return
+ }
+ reg = GetRegistry(
+ WithSpecDirs(
+ filepath.Join(dir, "etc"),
+ filepath.Join(dir, "run"),
+ ),
+ )
+ require.Nil(t, err)
+ require.NotNil(t, reg)
+
+ devices := reg.DeviceDB().ListDevices()
+ require.Equal(t, tc.devices, devices)
+ })
+ }
+}
diff --git a/pkg/cdi/regressions_test.go b/pkg/cdi/regressions_test.go
new file mode 100644
index 0000000..d3ce12a
--- /dev/null
+++ b/pkg/cdi/regressions_test.go
@@ -0,0 +1,183 @@
+package cdi
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCDIInjectionRace(t *testing.T) {
+ // This is a gutted version of a containerd test case which triggered
+ // read/write data race in the Cache.
+
+ for _, test := range []struct {
+ description string
+ cdiSpecFiles []string
+ annotations map[string]string
+ expectError bool
+ expectDevices []oci.LinuxDevice
+ expectEnv []string
+ }{
+ {description: "expect no CDI error for nil annotations"},
+ {description: "expect no CDI error for empty annotations",
+ annotations: map[string]string{},
+ },
+ {description: "expect CDI error for invalid CDI device reference in annotations",
+ annotations: map[string]string{
+ AnnotationPrefix + "devices": "foobar",
+ },
+ expectError: true,
+ },
+ {description: "expect CDI error for unresolvable devices",
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor1_devices": "vendor1.com/device=no-such-dev",
+ },
+ expectError: true,
+ },
+ {description: "expect properly injected resolvable CDI devices",
+ cdiSpecFiles: []string{
+ `
+cdiVersion: "0.3.0"
+kind: "vendor1.com/device"
+devices:
+ - name: foo
+ containerEdits:
+ deviceNodes:
+ - path: /dev/loop8
+ type: b
+ major: 7
+ minor: 8
+ env:
+ - FOO=injected
+containerEdits:
+ env:
+ - "VENDOR1=present"
+`,
+ `
+cdiVersion: "0.3.0"
+kind: "vendor2.com/device"
+devices:
+ - name: bar
+ containerEdits:
+ deviceNodes:
+ - path: /dev/loop9
+ type: b
+ major: 7
+ minor: 9
+ env:
+ - BAR=injected
+containerEdits:
+ env:
+ - "VENDOR2=present"
+`,
+ },
+ annotations: map[string]string{
+ AnnotationPrefix + "vendor1_devices": "vendor1.com/device=foo",
+ AnnotationPrefix + "vendor2_devices": "vendor2.com/device=bar",
+ },
+ expectDevices: []oci.LinuxDevice{
+ {
+ Path: "/dev/loop8",
+ Type: "b",
+ Major: 7,
+ Minor: 8,
+ },
+ {
+ Path: "/dev/loop9",
+ Type: "b",
+ Major: 7,
+ Minor: 9,
+ },
+ },
+ expectEnv: []string{
+ "FOO=injected",
+ "VENDOR1=present",
+ "BAR=injected",
+ "VENDOR2=present",
+ },
+ },
+ } {
+ t.Run(test.description, func(t *testing.T) {
+ var (
+ err error
+ spec = &oci.Spec{}
+ )
+
+ cdiDir, err := writeFilesToTempDir("containerd-test-CDI-injections-", test.cdiSpecFiles)
+ if cdiDir != "" {
+ defer os.RemoveAll(cdiDir)
+ }
+ require.NoError(t, err)
+
+ injectFun := withCDI(t, test.annotations, []string{cdiDir})
+ err = injectFun(spec)
+ assert.Equal(t, test.expectError, err != nil)
+
+ if err != nil {
+ if test.expectEnv != nil {
+ for _, expectedEnv := range test.expectEnv {
+ assert.Contains(t, spec.Process.Env, expectedEnv)
+ }
+ }
+ if test.expectDevices != nil {
+ for _, expectedDev := range test.expectDevices {
+ assert.Contains(t, spec.Linux.Devices, expectedDev)
+ }
+ }
+ }
+ })
+ }
+}
+
+type specOpts func(*oci.Spec) error
+
+// withCDI (WithCDI) SpecOpt adopted from containerd.
+func withCDI(t *testing.T, annotations map[string]string, cdiSpecDirs []string) specOpts {
+ return func(s *oci.Spec) error {
+ _, cdiDevices, err := ParseAnnotations(annotations)
+ if err != nil {
+ return fmt.Errorf("failed to parse CDI device annotations: %w", err)
+ }
+ if cdiDevices == nil {
+ return nil
+ }
+
+ registry := GetRegistry(WithSpecDirs(cdiSpecDirs...))
+ if err = registry.Refresh(); err != nil {
+ t.Logf("CDI registry refresh failed: %v", err)
+ }
+
+ if _, err := registry.InjectDevices(s, cdiDevices...); err != nil {
+ return fmt.Errorf("CDI device injection failed: %w", err)
+ }
+
+ return nil
+ }
+}
+
+func writeFilesToTempDir(tmpDirPattern string, content []string) (string, error) {
+ if len(content) == 0 {
+ return "", nil
+ }
+
+ dir, err := os.MkdirTemp("", tmpDirPattern)
+ if err != nil {
+ return "", err
+ }
+
+ for idx, data := range content {
+ file := filepath.Join(dir, fmt.Sprintf("spec-%d.yaml", idx))
+ err := os.WriteFile(file, []byte(data), 0644)
+ if err != nil {
+ return "", err
+ }
+ }
+
+ return dir, nil
+}
diff --git a/pkg/cdi/spec-dirs.go b/pkg/cdi/spec-dirs.go
new file mode 100644
index 0000000..f339349
--- /dev/null
+++ b/pkg/cdi/spec-dirs.go
@@ -0,0 +1,114 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+const (
+ // DefaultStaticDir is the default directory for static CDI Specs.
+ DefaultStaticDir = "/etc/cdi"
+ // DefaultDynamicDir is the default directory for generated CDI Specs
+ DefaultDynamicDir = "/var/run/cdi"
+)
+
+var (
+ // DefaultSpecDirs is the default Spec directory configuration.
+ // While altering this variable changes the package defaults,
+ // the preferred way of overriding the default directories is
+ // to use a WithSpecDirs options. Otherwise the change is only
+ // effective if it takes place before creating the Registry or
+ // other Cache instances.
+ DefaultSpecDirs = []string{DefaultStaticDir, DefaultDynamicDir}
+ // ErrStopScan can be returned from a ScanSpecFunc to stop the scan.
+ ErrStopScan = errors.New("stop Spec scan")
+)
+
+// WithSpecDirs returns an option to override the CDI Spec directories.
+func WithSpecDirs(dirs ...string) Option {
+ return func(c *Cache) error {
+ specDirs := make([]string, len(dirs))
+ for i, dir := range dirs {
+ specDirs[i] = filepath.Clean(dir)
+ }
+ c.specDirs = specDirs
+ return nil
+ }
+}
+
+// scanSpecFunc is a function for processing CDI Spec files.
+type scanSpecFunc func(string, int, *Spec, error) error
+
+// ScanSpecDirs scans the given directories looking for CDI Spec files,
+// which are all files with a '.json' or '.yaml' suffix. For every Spec
+// file discovered, ScanSpecDirs loads a Spec from the file then calls
+// the scan function passing it the path to the file, the priority (the
+// index of the directory in the slice of directories given), the Spec
+// itself, and any error encountered while loading the Spec.
+//
+// Scanning stops once all files have been processed or when the scan
+// function returns an error. The result of ScanSpecDirs is the error
+// returned by the scan function, if any. The special error ErrStopScan
+// can be used to terminate the scan gracefully without ScanSpecDirs
+// returning an error. ScanSpecDirs silently skips any subdirectories.
+func scanSpecDirs(dirs []string, scanFn scanSpecFunc) error {
+ var (
+ spec *Spec
+ err error
+ )
+
+ for priority, dir := range dirs {
+ err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ // for initial stat failure Walk calls us with nil info
+ if info == nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+ // first call from Walk is for dir itself, others we skip
+ if info.IsDir() {
+ if path == dir {
+ return nil
+ }
+ return filepath.SkipDir
+ }
+
+ // ignore obviously non-Spec files
+ if ext := filepath.Ext(path); ext != ".json" && ext != ".yaml" {
+ return nil
+ }
+
+ if err != nil {
+ return scanFn(path, priority, nil, err)
+ }
+
+ spec, err = ReadSpec(path, priority)
+ return scanFn(path, priority, spec, err)
+ })
+
+ if err != nil && err != ErrStopScan {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/cdi/spec-dirs_test.go b/pkg/cdi/spec-dirs_test.go
new file mode 100644
index 0000000..50ff0ab
--- /dev/null
+++ b/pkg/cdi/spec-dirs_test.go
@@ -0,0 +1,249 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestScanSpecDirs(t *testing.T) {
+ type testCase struct {
+ name string
+ files map[string]string
+ success map[string]struct{}
+ failure map[string]struct{}
+ vendors map[string]string
+ classes map[string]string
+ abort bool
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "no directory",
+ files: nil,
+ },
+ {
+ name: "no files",
+ files: map[string]string{},
+ success: map[string]struct{}{},
+ failure: map[string]struct{}{},
+ vendors: map[string]string{},
+ classes: map[string]string{},
+ },
+ {
+ name: "one valid file",
+ files: map[string]string{
+ "valid.yaml": `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ },
+ success: map[string]struct{}{
+ "valid.yaml": {},
+ },
+ failure: map[string]struct{}{},
+ vendors: map[string]string{
+ "valid.yaml": "vendor.com",
+ },
+ classes: map[string]string{
+ "valid.yaml": "device",
+ },
+ },
+ {
+ name: "one invalid file",
+ files: map[string]string{
+ "invalid.yaml": `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+`,
+ },
+ success: map[string]struct{}{},
+ failure: map[string]struct{}{
+ "invalid.yaml": {},
+ },
+ vendors: map[string]string{},
+ classes: map[string]string{},
+ },
+ {
+ name: "two valid files, one invalid file",
+ files: map[string]string{
+ "valid1.yaml": `
+cdiVersion: "0.3.0"
+kind: vendor.com/device1
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ "valid2.yaml": `
+cdiVersion: "0.3.0"
+kind: vendor.com/device2
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ "invalid.yaml": `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+`,
+ },
+ success: map[string]struct{}{
+ "valid1.yaml": {},
+ "valid2.yaml": {},
+ },
+ failure: map[string]struct{}{
+ "invalid.yaml": {},
+ },
+ vendors: map[string]string{
+ "valid1.yaml": "vendor.com",
+ "valid2.yaml": "vendor.com",
+ },
+ classes: map[string]string{
+ "valid1.yaml": "device1",
+ "valid2.yaml": "device2",
+ },
+ },
+ {
+ // we assume running on an fs with sorted readdir()...
+ name: "one valid file, one invalid file, abort on first error",
+ files: map[string]string{
+ "valid.yaml": `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ "invalid.yaml": `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+`,
+ },
+ success: map[string]struct{}{},
+ failure: map[string]struct{}{
+ "invalid.yaml": {},
+ },
+ vendors: map[string]string{},
+ classes: map[string]string{},
+ abort: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ dir string
+ err error
+ success map[string]struct{}
+ failure map[string]struct{}
+ vendors map[string]string
+ classes map[string]string
+ )
+ if tc.files != nil {
+ dir, err = mkTestDir(t, map[string]map[string]string{
+ "etc": tc.files,
+ })
+ if err != nil {
+ t.Errorf("failed to populate test directory: %v", err)
+ }
+ dir = filepath.Join(dir, "etc")
+ success = map[string]struct{}{}
+ failure = map[string]struct{}{}
+ vendors = map[string]string{}
+ classes = map[string]string{}
+ }
+
+ dirs := []string{"/no-such-dir", dir}
+ err = scanSpecDirs(dirs, func(path string, prio int, spec *Spec, err error) error {
+ name := filepath.Base(path)
+ if err != nil {
+ failure[name] = struct{}{}
+ if tc.abort {
+ return err
+ }
+ } else {
+ success[name] = struct{}{}
+ vendors[name] = spec.GetVendor()
+ classes[name] = spec.GetClass()
+ }
+ return nil
+ })
+
+ require.Equal(t, tc.success, success)
+ require.Equal(t, tc.failure, failure)
+ require.Equal(t, tc.vendors, vendors)
+ require.Equal(t, tc.classes, classes)
+ })
+ }
+}
+
+// Create an automatically cleaned up temporary directory, with optional content.
+func mkTestDir(t *testing.T, dirs map[string]map[string]string) (string, error) {
+ tmp, err := ioutil.TempDir("", ".cache-test*")
+ if err != nil {
+ return "", fmt.Errorf("failed to create test directory: %w", err)
+ }
+
+ t.Cleanup(func() {
+ os.RemoveAll(tmp)
+ })
+
+ if err = updateTestDir(t, tmp, dirs); err != nil {
+ return "", err
+ }
+
+ return tmp, nil
+}
+
+func updateTestDir(t *testing.T, tmp string, dirs map[string]map[string]string) error {
+ for sub, content := range dirs {
+ dir := filepath.Join(tmp, sub)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create directory %q: %w", dir, err)
+ }
+ for file, data := range content {
+ file := filepath.Join(dir, file)
+ tmp := file + ".tmp"
+ if err := ioutil.WriteFile(tmp, []byte(data), 0644); err != nil {
+ return fmt.Errorf("failed to write file %q: %w", tmp, err)
+ }
+ if err := os.Rename(tmp, file); err != nil {
+ return fmt.Errorf("failed to rename %q to %q: %w", tmp, file, err)
+ }
+ }
+ }
+ return nil
+}
diff --git a/pkg/cdi/spec.go b/pkg/cdi/spec.go
new file mode 100644
index 0000000..8bd63cc
--- /dev/null
+++ b/pkg/cdi/spec.go
@@ -0,0 +1,352 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ oci "github.com/opencontainers/runtime-spec/specs-go"
+ "sigs.k8s.io/yaml"
+
+ "tags.cncf.io/container-device-interface/internal/validation"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+const (
+ // defaultSpecExt is the file extension for the default encoding.
+ defaultSpecExt = ".yaml"
+)
+
+var (
+ // Externally set CDI Spec validation function.
+ specValidator func(*cdi.Spec) error
+ validatorLock sync.RWMutex
+)
+
+// Spec represents a single CDI Spec. It is usually loaded from a
+// file and stored in a cache. The Spec has an associated priority.
+// This priority is inherited from the associated priority of the
+// CDI Spec directory that contains the CDI Spec file and is used
+// to resolve conflicts if multiple CDI Spec files contain entries
+// for the same fully qualified device.
+type Spec struct {
+ *cdi.Spec
+ vendor string
+ class string
+ path string
+ priority int
+ devices map[string]*Device
+}
+
+// ReadSpec reads the given CDI Spec file. The resulting Spec is
+// assigned the given priority. If reading or parsing the Spec
+// data fails ReadSpec returns a nil Spec and an error.
+func ReadSpec(path string, priority int) (*Spec, error) {
+ data, err := ioutil.ReadFile(path)
+ switch {
+ case os.IsNotExist(err):
+ return nil, err
+ case err != nil:
+ return nil, fmt.Errorf("failed to read CDI Spec %q: %w", path, err)
+ }
+
+ raw, err := ParseSpec(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse CDI Spec %q: %w", path, err)
+ }
+ if raw == nil {
+ return nil, fmt.Errorf("failed to parse CDI Spec %q, no Spec data", path)
+ }
+
+ spec, err := newSpec(raw, path, priority)
+ if err != nil {
+ return nil, err
+ }
+
+ return spec, nil
+}
+
+// newSpec creates a new Spec from the given CDI Spec data. The
+// Spec is marked as loaded from the given path with the given
+// priority. If Spec data validation fails newSpec returns a nil
+// Spec and an error.
+func newSpec(raw *cdi.Spec, path string, priority int) (*Spec, error) {
+ err := validateSpec(raw)
+ if err != nil {
+ return nil, err
+ }
+
+ spec := &Spec{
+ Spec: raw,
+ path: filepath.Clean(path),
+ priority: priority,
+ }
+
+ if ext := filepath.Ext(spec.path); ext != ".yaml" && ext != ".json" {
+ spec.path += defaultSpecExt
+ }
+
+ spec.vendor, spec.class = ParseQualifier(spec.Kind)
+
+ if spec.devices, err = spec.validate(); err != nil {
+ return nil, fmt.Errorf("invalid CDI Spec: %w", err)
+ }
+
+ return spec, nil
+}
+
+// Write the CDI Spec to the file associated with it during instantiation
+// by newSpec() or ReadSpec().
+func (s *Spec) write(overwrite bool) error {
+ var (
+ data []byte
+ dir string
+ tmp *os.File
+ err error
+ )
+
+ err = validateSpec(s.Spec)
+ if err != nil {
+ return err
+ }
+
+ if filepath.Ext(s.path) == ".yaml" {
+ data, err = yaml.Marshal(s.Spec)
+ data = append([]byte("---\n"), data...)
+ } else {
+ data, err = json.Marshal(s.Spec)
+ }
+ if err != nil {
+ return fmt.Errorf("failed to marshal Spec file: %w", err)
+ }
+
+ dir = filepath.Dir(s.path)
+ err = os.MkdirAll(dir, 0o755)
+ if err != nil {
+ return fmt.Errorf("failed to create Spec dir: %w", err)
+ }
+
+ tmp, err = os.CreateTemp(dir, "spec.*.tmp")
+ if err != nil {
+ return fmt.Errorf("failed to create Spec file: %w", err)
+ }
+ _, err = tmp.Write(data)
+ tmp.Close()
+ if err != nil {
+ return fmt.Errorf("failed to write Spec file: %w", err)
+ }
+
+ err = renameIn(dir, filepath.Base(tmp.Name()), filepath.Base(s.path), overwrite)
+
+ if err != nil {
+ os.Remove(tmp.Name())
+ err = fmt.Errorf("failed to write Spec file: %w", err)
+ }
+
+ return err
+}
+
+// GetVendor returns the vendor of this Spec.
+func (s *Spec) GetVendor() string {
+ return s.vendor
+}
+
+// GetClass returns the device class of this Spec.
+func (s *Spec) GetClass() string {
+ return s.class
+}
+
+// GetDevice returns the device for the given unqualified name.
+func (s *Spec) GetDevice(name string) *Device {
+ return s.devices[name]
+}
+
+// GetPath returns the filesystem path of this Spec.
+func (s *Spec) GetPath() string {
+ return s.path
+}
+
+// GetPriority returns the priority of this Spec.
+func (s *Spec) GetPriority() int {
+ return s.priority
+}
+
+// ApplyEdits applies the Spec's global-scope container edits to an OCI Spec.
+func (s *Spec) ApplyEdits(ociSpec *oci.Spec) error {
+ return s.edits().Apply(ociSpec)
+}
+
+// edits returns the applicable global container edits for this spec.
+func (s *Spec) edits() *ContainerEdits {
+ return &ContainerEdits{&s.ContainerEdits}
+}
+
+// Validate the Spec.
+func (s *Spec) validate() (map[string]*Device, error) {
+ if err := validateVersion(s.Version); err != nil {
+ return nil, err
+ }
+
+ minVersion, err := MinimumRequiredVersion(s.Spec)
+ if err != nil {
+ return nil, fmt.Errorf("could not determine minimum required version: %v", err)
+ }
+ if newVersion(minVersion).IsGreaterThan(newVersion(s.Version)) {
+ return nil, fmt.Errorf("the spec version must be at least v%v", minVersion)
+ }
+
+ if err := ValidateVendorName(s.vendor); err != nil {
+ return nil, err
+ }
+ if err := ValidateClassName(s.class); err != nil {
+ return nil, err
+ }
+ if err := validation.ValidateSpecAnnotations(s.Kind, s.Annotations); err != nil {
+ return nil, err
+ }
+ if err := s.edits().Validate(); err != nil {
+ return nil, err
+ }
+
+ devices := make(map[string]*Device)
+ for _, d := range s.Devices {
+ dev, err := newDevice(s, d)
+ if err != nil {
+ return nil, fmt.Errorf("failed add device %q: %w", d.Name, err)
+ }
+ if _, conflict := devices[d.Name]; conflict {
+ return nil, fmt.Errorf("invalid spec, multiple device %q", d.Name)
+ }
+ devices[d.Name] = dev
+ }
+
+ return devices, nil
+}
+
+// validateVersion checks whether the specified spec version is supported.
+func validateVersion(version string) error {
+ if !validSpecVersions.isValidVersion(version) {
+ return fmt.Errorf("invalid version %q", version)
+ }
+
+ return nil
+}
+
+// ParseSpec parses CDI Spec data into a raw CDI Spec.
+func ParseSpec(data []byte) (*cdi.Spec, error) {
+ var raw *cdi.Spec
+ err := yaml.UnmarshalStrict(data, &raw)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal CDI Spec: %w", err)
+ }
+ return raw, nil
+}
+
+// SetSpecValidator sets a CDI Spec validator function. This function
+// is used for extra CDI Spec content validation whenever a Spec file
+// loaded (using ReadSpec() or written (using WriteSpec()).
+func SetSpecValidator(fn func(*cdi.Spec) error) {
+ validatorLock.Lock()
+ defer validatorLock.Unlock()
+ specValidator = fn
+}
+
+// validateSpec validates the Spec using the extneral validator.
+func validateSpec(raw *cdi.Spec) error {
+ validatorLock.RLock()
+ defer validatorLock.RUnlock()
+
+ if specValidator == nil {
+ return nil
+ }
+ err := specValidator(raw)
+ if err != nil {
+ return fmt.Errorf("Spec validation failed: %w", err)
+ }
+ return nil
+}
+
+// GenerateSpecName generates a vendor+class scoped Spec file name. The
+// name can be passed to WriteSpec() to write a Spec file to the file
+// system.
+//
+// vendor and class should match the vendor and class of the CDI Spec.
+// The file name is generated without a ".json" or ".yaml" extension.
+// The caller can append the desired extension to choose a particular
+// encoding. Otherwise WriteSpec() will use its default encoding.
+//
+// This function always returns the same name for the same vendor/class
+// combination. Therefore it cannot be used as such to generate multiple
+// Spec file names for a single vendor and class.
+func GenerateSpecName(vendor, class string) string {
+ return vendor + "-" + class
+}
+
+// GenerateTransientSpecName generates a vendor+class scoped transient
+// Spec file name. The name can be passed to WriteSpec() to write a Spec
+// file to the file system.
+//
+// Transient Specs are those whose lifecycle is tied to that of some
+// external entity, for instance a container. vendor and class should
+// match the vendor and class of the CDI Spec. transientID should be
+// unique among all CDI users on the same host that might generate
+// transient Spec files using the same vendor/class combination. If
+// the external entity to which the lifecycle of the transient Spec
+// is tied to has a unique ID of its own, then this is usually a
+// good choice for transientID.
+//
+// The file name is generated without a ".json" or ".yaml" extension.
+// The caller can append the desired extension to choose a particular
+// encoding. Otherwise WriteSpec() will use its default encoding.
+func GenerateTransientSpecName(vendor, class, transientID string) string {
+ transientID = strings.ReplaceAll(transientID, "/", "_")
+ return GenerateSpecName(vendor, class) + "_" + transientID
+}
+
+// GenerateNameForSpec generates a name for the given Spec using
+// GenerateSpecName with the vendor and class taken from the Spec.
+// On success it returns the generated name and a nil error. If
+// the Spec does not contain a valid vendor or class, it returns
+// an empty name and a non-nil error.
+func GenerateNameForSpec(raw *cdi.Spec) (string, error) {
+ vendor, class := ParseQualifier(raw.Kind)
+ if vendor == "" {
+ return "", fmt.Errorf("invalid vendor/class %q in Spec", raw.Kind)
+ }
+
+ return GenerateSpecName(vendor, class), nil
+}
+
+// GenerateNameForTransientSpec generates a name for the given transient
+// Spec using GenerateTransientSpecName with the vendor and class taken
+// from the Spec. On success it returns the generated name and a nil error.
+// If the Spec does not contain a valid vendor or class, it returns an
+// an empty name and a non-nil error.
+func GenerateNameForTransientSpec(raw *cdi.Spec, transientID string) (string, error) {
+ vendor, class := ParseQualifier(raw.Kind)
+ if vendor == "" {
+ return "", fmt.Errorf("invalid vendor/class %q in Spec", raw.Kind)
+ }
+
+ return GenerateTransientSpecName(vendor, class, transientID), nil
+}
diff --git a/pkg/cdi/spec_linux.go b/pkg/cdi/spec_linux.go
new file mode 100644
index 0000000..9ad2739
--- /dev/null
+++ b/pkg/cdi/spec_linux.go
@@ -0,0 +1,48 @@
+/*
+ Copyright © 2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "fmt"
+ "os"
+
+ "golang.org/x/sys/unix"
+)
+
+// Rename src to dst, both relative to the directory dir. If dst already exists
+// refuse renaming with an error unless overwrite is explicitly asked for.
+func renameIn(dir, src, dst string, overwrite bool) error {
+ var flags uint
+
+ dirf, err := os.Open(dir)
+ if err != nil {
+ return fmt.Errorf("rename failed: %w", err)
+ }
+ defer dirf.Close()
+
+ if !overwrite {
+ flags = unix.RENAME_NOREPLACE
+ }
+
+ dirFd := int(dirf.Fd())
+ err = unix.Renameat2(dirFd, src, dirFd, dst, flags)
+ if err != nil {
+ return fmt.Errorf("rename failed: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/cdi/spec_other.go b/pkg/cdi/spec_other.go
new file mode 100644
index 0000000..285e04e
--- /dev/null
+++ b/pkg/cdi/spec_other.go
@@ -0,0 +1,39 @@
+//go:build !linux
+// +build !linux
+
+/*
+ Copyright © 2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "os"
+ "path/filepath"
+)
+
+// Rename src to dst, both relative to the directory dir. If dst already exists
+// refuse renaming with an error unless overwrite is explicitly asked for.
+func renameIn(dir, src, dst string, overwrite bool) error {
+ src = filepath.Join(dir, src)
+ dst = filepath.Join(dir, dst)
+
+ _, err := os.Stat(dst)
+ if err == nil && !overwrite {
+ return os.ErrExist
+ }
+
+ return os.Rename(src, dst)
+}
diff --git a/pkg/cdi/spec_test.go b/pkg/cdi/spec_test.go
new file mode 100644
index 0000000..7bdd063
--- /dev/null
+++ b/pkg/cdi/spec_test.go
@@ -0,0 +1,682 @@
+/*
+ Copyright © 2021 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "sigs.k8s.io/yaml"
+
+ "github.com/stretchr/testify/require"
+ "tags.cncf.io/container-device-interface/pkg/cdi/validate"
+ "tags.cncf.io/container-device-interface/pkg/parser"
+ "tags.cncf.io/container-device-interface/schema"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+func TestReadSpec(t *testing.T) {
+ type testCase struct {
+ name string
+ data string
+ unparsable bool
+ invalid bool
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "unparsable",
+ data: `
+xyzzy: garbled
+`,
+ unparsable: true,
+ },
+ {
+ name: "invalid, missing CDI version",
+ data: `
+kind: "vendor.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ invalid: true,
+ },
+ {
+ name: "empty",
+ data: "",
+ unparsable: true,
+ },
+ {
+ name: "valid",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ file, err := mkTestSpec(t, []byte(tc.data))
+ if err != nil {
+ t.Errorf("failed to create CDI Spec test file: %v", err)
+ return
+ }
+
+ spec, err := ReadSpec(file, 0)
+ if tc.unparsable {
+ require.Error(t, err)
+ require.Nil(t, spec)
+ return
+ }
+ if tc.invalid {
+ require.Error(t, err)
+ require.Nil(t, spec)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, spec)
+ })
+ }
+}
+
+func TestNewSpec(t *testing.T) {
+ type testCase struct {
+ name string
+ data string
+ unparsable bool
+ schemaFail bool
+ invalid bool
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "unparsable",
+ data: `
+xyzzy: garbled
+`,
+ unparsable: true,
+ },
+ {
+ name: "invalid, missing CDI version",
+ data: `
+kind: "vendor.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ schemaFail: true,
+ },
+ {
+ name: "invalid, invalid CDI version",
+ data: `
+cdiVersion: "0.0.0"
+kind: "vendor.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ invalid: true,
+ },
+ {
+ name: "invalid, missing vendor/class",
+ data: `
+cdiVersion: "0.3.0"
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ schemaFail: true,
+ },
+ {
+ name: "invalid, invalid vendor",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com-/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ invalid: true,
+ },
+ {
+ name: "invalid, invalid class",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device=
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ invalid: true,
+ },
+ {
+ name: "invalid, missing required edits",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+`,
+ schemaFail: true,
+ },
+ {
+ name: "invalid, conflicting devices",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "BAR=FOO"
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "SPACE=BAR"
+`,
+ invalid: true,
+ },
+ {
+ name: "valid",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "BAR=FOO"
+ - name: "dev3"
+ containerEdits:
+ env:
+ - "SPACE=BAR"
+`,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ raw *cdi.Spec
+ spec *Spec
+ err error
+ )
+
+ raw, err = ParseSpec([]byte(tc.data))
+ if tc.unparsable {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+
+ spec, err = newSpec(raw, tc.name, 0)
+ if tc.invalid || tc.schemaFail {
+ require.Error(t, err)
+ require.Nil(t, spec)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, spec)
+ })
+ }
+}
+
+func TestWriteSpec(t *testing.T) {
+ type testCase struct {
+ name string
+ data string
+ invalid bool
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "invalid-spec1.yaml",
+ invalid: true,
+ data: `
+cdiVersion: ""
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ },
+ {
+ name: "invalid-spec2.yaml",
+ invalid: true,
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ },
+ {
+ name: "spec1.yaml",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+ - name: "dev2"
+ containerEdits:
+ env:
+ - "BAR=FOO"
+ - name: "dev3"
+ containerEdits:
+ env:
+ - "SPACE=BAR"
+`,
+ },
+ {
+ name: "spec2.yaml",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev4"
+ containerEdits:
+ env:
+ - "BAR=FOO"
+ - name: "dev5"
+ containerEdits:
+ env:
+ - "XYZ=ZY"
+ - name: "dev6"
+ containerEdits:
+ env:
+ - "BAR=SPACE"
+`,
+ },
+ {
+ name: "spec3.yaml",
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev7"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+ - name: "dev8"
+ containerEdits:
+ env:
+ - "FOOBAR=BARFOO"
+ - name: "dev9"
+ containerEdits:
+ env:
+ - "SPACE=BAR"
+`,
+ },
+ } {
+ dir, err := mkTestDir(t, nil)
+ require.NoError(t, err)
+
+ SetSpecValidator(validate.WithDefaultSchema())
+ defer SetSpecValidator(validate.WithSchema(schema.NopSchema()))
+
+ t.Run(tc.name, func(t *testing.T) {
+ var (
+ raw = &cdi.Spec{}
+ spec *Spec
+ chk *Spec
+ err error
+ )
+
+ err = yaml.Unmarshal([]byte(tc.data), raw)
+ require.NoError(t, err)
+
+ spec, err = newSpec(raw, filepath.Join(dir, tc.name), 0)
+ if tc.invalid {
+ require.Error(t, err, "newSpec with invalid data")
+ require.Nil(t, spec, "newSpec with invalid data")
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotNil(t, spec)
+
+ err = spec.write(true)
+ require.NoError(t, err)
+ _, err = os.Stat(spec.GetPath())
+ require.NoError(t, err, "spec.Write destination file")
+
+ err = spec.write(false)
+ require.Error(t, err)
+
+ chk, err = ReadSpec(spec.GetPath(), spec.GetPriority())
+ require.NoError(t, err)
+ require.NotNil(t, chk)
+ require.Equal(t, spec.Spec, chk.Spec)
+ })
+ }
+}
+
+func TestGetters(t *testing.T) {
+ type testCase struct {
+ name string
+ priority int
+ data string
+ invalid bool
+ vendor string
+ class string
+ }
+ for _, tc := range []*testCase{
+ {
+ name: "unparsable",
+ data: `
+xyzzy: garbled
+`,
+ invalid: true,
+ },
+ {
+ name: "invalid, missing CDI version",
+ data: `
+kind: "vendor.com/device"
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ invalid: true,
+ },
+ {
+ name: "valid",
+ priority: 1,
+ data: `
+cdiVersion: "0.3.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ env:
+ - "FOO=BAR"
+`,
+ vendor: "vendor.com",
+ class: "device",
+ },
+ {
+ name: "valid",
+ priority: 1,
+ data: `
+cdiVersion: "0.4.0"
+kind: vendor.com/device
+devices:
+ - name: "dev1"
+ containerEdits:
+ mounts:
+ - hostPath: "tmpfs"
+ containerPath: "/usr/local/container"
+ type: "tmpfs"
+ options:
+ - "ro"
+ - "mode=755"
+ - "size=65536k"
+ env:
+ - "FOO=BAR"
+`,
+ vendor: "vendor.com",
+ class: "device",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ file, err := mkTestSpec(t, []byte(tc.data))
+ if err != nil {
+ t.Errorf("failed to create CDI Spec test file: %v", err)
+ return
+ }
+
+ spec, err := ReadSpec(file, tc.priority)
+ if tc.invalid {
+ require.Error(t, err)
+ require.Nil(t, spec)
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotNil(t, spec)
+ require.Equal(t, file, spec.GetPath())
+ require.Equal(t, tc.priority, spec.GetPriority())
+
+ vendor, class := spec.GetVendor(), spec.GetClass()
+ require.Equal(t, tc.vendor, vendor)
+ require.Equal(t, tc.class, class)
+
+ for name, d := range spec.devices {
+ require.Equal(t, spec, d.GetSpec())
+ require.Equal(t, d, spec.GetDevice(name))
+ require.Equal(t, parser.QualifiedName(vendor, class, name), d.GetQualifiedName())
+ }
+ })
+ }
+}
+
+// Create an automatically cleaned up temporary file for a test.
+func mkTestSpec(t *testing.T, data []byte) (string, error) {
+ tmp, err := ioutil.TempFile("", ".cdi-test.*."+specType(data))
+ if err != nil {
+ return "", fmt.Errorf("failed to create test file: %w", err)
+ }
+
+ if data != nil {
+ _, err := tmp.Write(data)
+ if err != nil {
+ return "", fmt.Errorf("failed to write test file content: %w", err)
+ }
+ }
+
+ file := tmp.Name()
+ t.Cleanup(func() {
+ os.Remove(file)
+ })
+
+ tmp.Close()
+ return file, nil
+}
+
+func specType(content []byte) string {
+ spec := strings.TrimSpace(string(content))
+ if spec != "" && spec[0] == '{' {
+ return "json"
+ }
+ return "yaml"
+}
+
+func TestCurrentVersionIsValid(t *testing.T) {
+ require.NoError(t, validateVersion(cdi.CurrentVersion))
+}
+
+func TestRequiredVersion(t *testing.T) {
+
+ testCases := []struct {
+ description string
+ spec *cdi.Spec
+ expectedVersion string
+ }{
+ {
+ description: "empty spec returns lowest version",
+ spec: &cdi.Spec{},
+ expectedVersion: "0.3.0",
+ },
+ {
+ description: "hostPath set returns version 0.5.0",
+ spec: &cdi.Spec{
+ ContainerEdits: cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ HostPath: "/host/path/set",
+ },
+ },
+ },
+ },
+ expectedVersion: "0.5.0",
+ },
+ {
+ description: "hostPath equal to Path required v0.5.0",
+ spec: &cdi.Spec{
+ ContainerEdits: cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ HostPath: "/some/path",
+ Path: "/some/path",
+ },
+ },
+ },
+ },
+ expectedVersion: "0.5.0",
+ },
+ {
+ description: "mount type set returns version 0.4.0",
+ spec: &cdi.Spec{
+ ContainerEdits: cdi.ContainerEdits{
+ Mounts: []*cdi.Mount{
+ {
+ Type: "bind",
+ },
+ },
+ },
+ },
+ expectedVersion: "0.4.0",
+ },
+ {
+ description: "newest required version is selected",
+ spec: &cdi.Spec{
+ Annotations: map[string]string{
+ "key": "value",
+ },
+ ContainerEdits: cdi.ContainerEdits{
+ DeviceNodes: []*cdi.DeviceNode{
+ {
+ HostPath: "/host/path/set",
+ },
+ },
+ Mounts: []*cdi.Mount{
+ {
+ Type: "bind",
+ },
+ },
+ },
+ },
+ expectedVersion: "0.6.0",
+ },
+ {
+ description: "device with name starting with digit requires v0.5.0",
+ spec: &cdi.Spec{
+ Devices: []cdi.Device{
+ {
+ Name: "0",
+ ContainerEdits: cdi.ContainerEdits{
+ Env: []string{
+ "FOO=bar",
+ },
+ },
+ },
+ },
+ },
+ expectedVersion: "0.5.0",
+ },
+ {
+ description: "device with name starting with letter requires minimum version",
+ spec: &cdi.Spec{
+ Devices: []cdi.Device{
+ {
+ Name: "device0",
+ ContainerEdits: cdi.ContainerEdits{
+ Env: []string{
+ "FOO=bar",
+ },
+ },
+ },
+ },
+ },
+ expectedVersion: "0.3.0",
+ },
+ {
+ description: "spec annotations require v0.6.0",
+ spec: &cdi.Spec{
+ Annotations: map[string]string{
+ "key": "value",
+ },
+ },
+ expectedVersion: "0.6.0",
+ },
+ {
+ description: "device annotations require v0.6.0",
+ spec: &cdi.Spec{
+ Devices: []cdi.Device{
+ {
+ Name: "device0",
+ Annotations: map[string]string{
+ "key": "value",
+ },
+ ContainerEdits: cdi.ContainerEdits{
+ Env: []string{
+ "FOO=bar",
+ },
+ },
+ },
+ },
+ },
+ expectedVersion: "0.6.0",
+ },
+ {
+ description: "dotted name (class) label require v0.6.0",
+ spec: &cdi.Spec{
+ Kind: "vendor.com/class.sub",
+ },
+ expectedVersion: "0.6.0",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.description, func(t *testing.T) {
+ v, err := MinimumRequiredVersion(tc.spec)
+ require.NoError(t, err)
+
+ require.Equal(t, tc.expectedVersion, v)
+ })
+ }
+}
diff --git a/pkg/cdi/validate/schema.go b/pkg/cdi/validate/schema.go
new file mode 100644
index 0000000..b04e796
--- /dev/null
+++ b/pkg/cdi/validate/schema.go
@@ -0,0 +1,58 @@
+/*
+ Copyright © 2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package validate
+
+import (
+ "tags.cncf.io/container-device-interface/schema"
+ raw "tags.cncf.io/container-device-interface/specs-go"
+)
+
+const (
+ // DefaultExternalSchema is the default JSON schema to load for validation.
+ DefaultExternalSchema = "/etc/cdi/schema/schema.json"
+)
+
+// WithSchema returns a CDI Spec validator that uses the given Schema.
+func WithSchema(s *schema.Schema) func(*raw.Spec) error {
+ if s == nil {
+ return func(*raw.Spec) error {
+ return nil
+ }
+ }
+ return func(spec *raw.Spec) error {
+ return s.ValidateType(spec)
+ }
+}
+
+// WithNamedSchema loads the named JSON schema and returns a CDI Spec
+// validator for it. If loading the schema fails a dummy validator is
+// returned.
+func WithNamedSchema(name string) func(*raw.Spec) error {
+ s, _ := schema.Load(name)
+ return WithSchema(s)
+}
+
+// WithDefaultSchema returns a CDI Spec validator that uses the default
+// external JSON schema, or the default builtin one if the external one
+// fails to load.
+func WithDefaultSchema() func(*raw.Spec) error {
+ s, err := schema.Load(DefaultExternalSchema)
+ if err == nil {
+ return WithSchema(s)
+ }
+ return WithSchema(schema.BuiltinSchema())
+}
diff --git a/pkg/cdi/version.go b/pkg/cdi/version.go
new file mode 100644
index 0000000..a617812
--- /dev/null
+++ b/pkg/cdi/version.go
@@ -0,0 +1,188 @@
+/*
+ Copyright © The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cdi
+
+import (
+ "strings"
+
+ "golang.org/x/mod/semver"
+
+ "tags.cncf.io/container-device-interface/pkg/parser"
+ cdi "tags.cncf.io/container-device-interface/specs-go"
+)
+
+const (
+ // CurrentVersion is the current version of the CDI Spec.
+ CurrentVersion = cdi.CurrentVersion
+
+ // vCurrent is the current version as a semver-comparable type
+ vCurrent version = "v" + CurrentVersion
+
+ // These represent the released versions of the CDI specification
+ v010 version = "v0.1.0"
+ v020 version = "v0.2.0"
+ v030 version = "v0.3.0"
+ v040 version = "v0.4.0"
+ v050 version = "v0.5.0"
+ v060 version = "v0.6.0"
+
+ // vEarliest is the earliest supported version of the CDI specification
+ vEarliest version = v030
+)
+
+// validSpecVersions stores a map of spec versions to functions to check the required versions.
+// Adding new fields / spec versions requires that a `requiredFunc` be implemented and
+// this map be updated.
+var validSpecVersions = requiredVersionMap{
+ v010: nil,
+ v020: nil,
+ v030: nil,
+ v040: requiresV040,
+ v050: requiresV050,
+ v060: requiresV060,
+}
+
+// MinimumRequiredVersion determines the minimum spec version for the input spec.
+func MinimumRequiredVersion(spec *cdi.Spec) (string, error) {
+ minVersion := validSpecVersions.requiredVersion(spec)
+ return minVersion.String(), nil
+}
+
+// version represents a semantic version string
+type version string
+
+// newVersion creates a version that can be used for semantic version comparisons.
+func newVersion(v string) version {
+ return version("v" + strings.TrimPrefix(v, "v"))
+}
+
+// String returns the string representation of the version.
+// This trims a leading v if present.
+func (v version) String() string {
+ return strings.TrimPrefix(string(v), "v")
+}
+
+// IsGreaterThan checks with a version is greater than the specified version.
+func (v version) IsGreaterThan(o version) bool {
+ return semver.Compare(string(v), string(o)) > 0
+}
+
+// IsLatest checks whether the version is the latest supported version
+func (v version) IsLatest() bool {
+ return v == vCurrent
+}
+
+type requiredFunc func(*cdi.Spec) bool
+
+type requiredVersionMap map[version]requiredFunc
+
+// isValidVersion checks whether the specified version is valid.
+// A version is valid if it is contained in the required version map.
+func (r requiredVersionMap) isValidVersion(specVersion string) bool {
+ _, ok := validSpecVersions[newVersion(specVersion)]
+
+ return ok
+}
+
+// requiredVersion returns the minimum version required for the given spec
+func (r requiredVersionMap) requiredVersion(spec *cdi.Spec) version {
+ minVersion := vEarliest
+
+ for v, isRequired := range validSpecVersions {
+ if isRequired == nil {
+ continue
+ }
+ if isRequired(spec) && v.IsGreaterThan(minVersion) {
+ minVersion = v
+ }
+ // If we have already detected the latest version then no later version could be detected
+ if minVersion.IsLatest() {
+ break
+ }
+ }
+
+ return minVersion
+}
+
+// requiresV060 returns true if the spec uses v0.6.0 features
+func requiresV060(spec *cdi.Spec) bool {
+ // The v0.6.0 spec allows annotations to be specified at a spec level
+ for range spec.Annotations {
+ return true
+ }
+
+ // The v0.6.0 spec allows annotations to be specified at a device level
+ for _, d := range spec.Devices {
+ for range d.Annotations {
+ return true
+ }
+ }
+
+ // The v0.6.0 spec allows dots "." in Kind name label (class)
+ vendor, class := parser.ParseQualifier(spec.Kind)
+ if vendor != "" {
+ if strings.ContainsRune(class, '.') {
+ return true
+ }
+ }
+
+ return false
+}
+
+// requiresV050 returns true if the spec uses v0.5.0 features
+func requiresV050(spec *cdi.Spec) bool {
+ var edits []*cdi.ContainerEdits
+
+ for _, d := range spec.Devices {
+ // The v0.5.0 spec allowed device names to start with a digit instead of requiring a letter
+ if len(d.Name) > 0 && !parser.IsLetter(rune(d.Name[0])) {
+ return true
+ }
+ edits = append(edits, &d.ContainerEdits)
+ }
+
+ edits = append(edits, &spec.ContainerEdits)
+ for _, e := range edits {
+ for _, dn := range e.DeviceNodes {
+ // The HostPath field was added in v0.5.0
+ if dn.HostPath != "" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// requiresV040 returns true if the spec uses v0.4.0 features
+func requiresV040(spec *cdi.Spec) bool {
+ var edits []*cdi.ContainerEdits
+
+ for _, d := range spec.Devices {
+ edits = append(edits, &d.ContainerEdits)
+ }
+
+ edits = append(edits, &spec.ContainerEdits)
+ for _, e := range edits {
+ for _, m := range e.Mounts {
+ // The Type field was added in v0.4.0
+ if m.Type != "" {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go
new file mode 100644
index 0000000..5325989
--- /dev/null
+++ b/pkg/parser/parser.go
@@ -0,0 +1,212 @@
+/*
+ Copyright © The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package parser
+
+import (
+ "fmt"
+ "strings"
+)
+
+// QualifiedName returns the qualified name for a device.
+// The syntax for a qualified device names is
+//
+// "<vendor>/<class>=<name>".
+//
+// A valid vendor and class name may contain the following runes:
+//
+// 'A'-'Z', 'a'-'z', '0'-'9', '.', '-', '_'.
+//
+// A valid device name may contain the following runes:
+//
+// 'A'-'Z', 'a'-'z', '0'-'9', '-', '_', '.', ':'
+func QualifiedName(vendor, class, name string) string {
+ return vendor + "/" + class + "=" + name
+}
+
+// IsQualifiedName tests if a device name is qualified.
+func IsQualifiedName(device string) bool {
+ _, _, _, err := ParseQualifiedName(device)
+ return err == nil
+}
+
+// ParseQualifiedName splits a qualified name into device vendor, class,
+// and name. If the device fails to parse as a qualified name, or if any
+// of the split components fail to pass syntax validation, vendor and
+// class are returned as empty, together with the verbatim input as the
+// name and an error describing the reason for failure.
+func ParseQualifiedName(device string) (string, string, string, error) {
+ vendor, class, name := ParseDevice(device)
+
+ if vendor == "" {
+ return "", "", device, fmt.Errorf("unqualified device %q, missing vendor", device)
+ }
+ if class == "" {
+ return "", "", device, fmt.Errorf("unqualified device %q, missing class", device)
+ }
+ if name == "" {
+ return "", "", device, fmt.Errorf("unqualified device %q, missing device name", device)
+ }
+
+ if err := ValidateVendorName(vendor); err != nil {
+ return "", "", device, fmt.Errorf("invalid device %q: %w", device, err)
+ }
+ if err := ValidateClassName(class); err != nil {
+ return "", "", device, fmt.Errorf("invalid device %q: %w", device, err)
+ }
+ if err := ValidateDeviceName(name); err != nil {
+ return "", "", device, fmt.Errorf("invalid device %q: %w", device, err)
+ }
+
+ return vendor, class, name, nil
+}
+
+// ParseDevice tries to split a device name into vendor, class, and name.
+// If this fails, for instance in the case of unqualified device names,
+// ParseDevice returns an empty vendor and class together with name set
+// to the verbatim input.
+func ParseDevice(device string) (string, string, string) {
+ if device == "" || device[0] == '/' {
+ return "", "", device
+ }
+
+ parts := strings.SplitN(device, "=", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ return "", "", device
+ }
+
+ name := parts[1]
+ vendor, class := ParseQualifier(parts[0])
+ if vendor == "" {
+ return "", "", device
+ }
+
+ return vendor, class, name
+}
+
+// ParseQualifier splits a device qualifier into vendor and class.
+// The syntax for a device qualifier is
+//
+// "<vendor>/<class>"
+//
+// If parsing fails, an empty vendor and the class set to the
+// verbatim input is returned.
+func ParseQualifier(kind string) (string, string) {
+ parts := strings.SplitN(kind, "/", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ return "", kind
+ }
+ return parts[0], parts[1]
+}
+
+// ValidateVendorName checks the validity of a vendor name.
+// A vendor name may contain the following ASCII characters:
+// - upper- and lowercase letters ('A'-'Z', 'a'-'z')
+// - digits ('0'-'9')
+// - underscore, dash, and dot ('_', '-', and '.')
+func ValidateVendorName(vendor string) error {
+ err := validateVendorOrClassName(vendor)
+ if err != nil {
+ err = fmt.Errorf("invalid vendor. %w", err)
+ }
+ return err
+}
+
+// ValidateClassName checks the validity of class name.
+// A class name may contain the following ASCII characters:
+// - upper- and lowercase letters ('A'-'Z', 'a'-'z')
+// - digits ('0'-'9')
+// - underscore, dash, and dot ('_', '-', and '.')
+func ValidateClassName(class string) error {
+ err := validateVendorOrClassName(class)
+ if err != nil {
+ err = fmt.Errorf("invalid class. %w", err)
+ }
+ return err
+}
+
+// validateVendorOrClassName checks the validity of vendor or class name.
+// A name may contain the following ASCII characters:
+// - upper- and lowercase letters ('A'-'Z', 'a'-'z')
+// - digits ('0'-'9')
+// - underscore, dash, and dot ('_', '-', and '.')
+func validateVendorOrClassName(name string) error {
+ if name == "" {
+ return fmt.Errorf("empty name")
+ }
+ if !IsLetter(rune(name[0])) {
+ return fmt.Errorf("%q, should start with letter", name)
+ }
+ for _, c := range string(name[1 : len(name)-1]) {
+ switch {
+ case IsAlphaNumeric(c):
+ case c == '_' || c == '-' || c == '.':
+ default:
+ return fmt.Errorf("invalid character '%c' in name %q",
+ c, name)
+ }
+ }
+ if !IsAlphaNumeric(rune(name[len(name)-1])) {
+ return fmt.Errorf("%q, should end with a letter or digit", name)
+ }
+
+ return nil
+}
+
+// ValidateDeviceName checks the validity of a device name.
+// A device name may contain the following ASCII characters:
+// - upper- and lowercase letters ('A'-'Z', 'a'-'z')
+// - digits ('0'-'9')
+// - underscore, dash, dot, colon ('_', '-', '.', ':')
+func ValidateDeviceName(name string) error {
+ if name == "" {
+ return fmt.Errorf("invalid (empty) device name")
+ }
+ if !IsAlphaNumeric(rune(name[0])) {
+ return fmt.Errorf("invalid class %q, should start with a letter or digit", name)
+ }
+ if len(name) == 1 {
+ return nil
+ }
+ for _, c := range string(name[1 : len(name)-1]) {
+ switch {
+ case IsAlphaNumeric(c):
+ case c == '_' || c == '-' || c == '.' || c == ':':
+ default:
+ return fmt.Errorf("invalid character '%c' in device name %q",
+ c, name)
+ }
+ }
+ if !IsAlphaNumeric(rune(name[len(name)-1])) {
+ return fmt.Errorf("invalid name %q, should end with a letter or digit", name)
+ }
+ return nil
+}
+
+// IsLetter reports whether the rune is a letter.
+func IsLetter(c rune) bool {
+ return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z')
+}
+
+// IsDigit reports whether the rune is a digit.
+func IsDigit(c rune) bool {
+ return '0' <= c && c <= '9'
+}
+
+// IsAlphaNumeric reports whether the rune is a letter or digit.
+func IsAlphaNumeric(c rune) bool {
+ return IsLetter(c) || IsDigit(c)
+}
diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go
new file mode 100644
index 0000000..ecd9138
--- /dev/null
+++ b/pkg/parser/parser_test.go
@@ -0,0 +1,153 @@
+/*
+ Copyright © The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package parser
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestQualifiedName(t *testing.T) {
+ type testCase = struct {
+ device string
+ vendor string
+ class string
+ name string
+ isQualified bool
+ isParsable bool
+ }
+
+ for _, tc := range []*testCase{
+ {
+ device: "vendor.com/class=dev",
+ vendor: "vendor.com",
+ class: "class",
+ name: "dev",
+ isQualified: true,
+ },
+ {
+ device: "vendor.com/class=0",
+ vendor: "vendor.com",
+ class: "class",
+ name: "0",
+ isQualified: true,
+ },
+ {
+ device: "vendor1.com/class1=dev1",
+ vendor: "vendor1.com",
+ class: "class1",
+ name: "dev1",
+ isQualified: true,
+ },
+ {
+ device: "vendor1.com/class.subclass=dev1",
+ vendor: "vendor1.com",
+ class: "class.subclass",
+ name: "dev1",
+ isQualified: true,
+ },
+ {
+ device: "other-vendor1.com/class_1=dev_1",
+ vendor: "other-vendor1.com",
+ class: "class_1",
+ name: "dev_1",
+ isQualified: true,
+ },
+ {
+ device: "yet_another-vendor2.com/c-lass_2=dev_1:2.3",
+ vendor: "yet_another-vendor2.com",
+ class: "c-lass_2",
+ name: "dev_1:2.3",
+ isQualified: true,
+ },
+ {
+ device: "_invalid.com/class=dev",
+ vendor: "_invalid.com",
+ class: "class",
+ name: "dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid2.com-/class=dev",
+ vendor: "invalid2.com-",
+ class: "class",
+ name: "dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid3.com/_class=dev",
+ vendor: "invalid3.com",
+ class: "_class",
+ name: "dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid4.com/class_=dev",
+ vendor: "invalid4.com",
+ class: "class_",
+ name: "dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid5.com/class=-dev",
+ vendor: "invalid5.com",
+ class: "class",
+ name: "-dev",
+ isParsable: true,
+ },
+ {
+ device: "invalid6.com/class=dev:",
+ vendor: "invalid6.com",
+ class: "class",
+ name: "dev:",
+ isParsable: true,
+ },
+ {
+ device: "*.com/*dev=*gpu*",
+ vendor: "*.com",
+ class: "*dev",
+ name: "*gpu*",
+ isParsable: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ vendor, class, name, err := ParseQualifiedName(tc.device)
+ if tc.isQualified {
+ require.True(t, IsQualifiedName(tc.device), "qualified name %q", tc.device)
+ require.NoError(t, err)
+ require.Equal(t, tc.vendor, vendor, "qualified name %q", tc.device)
+ require.Equal(t, tc.class, class, "qualified name %q", tc.device)
+ require.Equal(t, tc.name, name, "qualified name %q", tc.device)
+
+ vendor, class, name = ParseDevice(tc.device)
+ require.Equal(t, tc.vendor, vendor, "parsed name %q", tc.device)
+ require.Equal(t, tc.class, class, "parse name %q", tc.device)
+ require.Equal(t, tc.name, name, "parsed name %q", tc.device)
+
+ device := QualifiedName(vendor, class, name)
+ require.Equal(t, tc.device, device, "constructed device %q", tc.device)
+ } else if tc.isParsable {
+ require.False(t, IsQualifiedName(tc.device), "parsed name %q", tc.device)
+ vendor, class, name = ParseDevice(tc.device)
+ require.Equal(t, tc.vendor, vendor, "parsed name %q", tc.device)
+ require.Equal(t, tc.class, class, "parse name %q", tc.device)
+ require.Equal(t, tc.name, name, "parsed name %q", tc.device)
+ }
+ })
+ }
+}
diff --git a/schema/Makefile b/schema/Makefile
new file mode 100644
index 0000000..976895f
--- /dev/null
+++ b/schema/Makefile
@@ -0,0 +1,27 @@
+VALIDATE ?= ../bin/validate
+SCHEMA ?= schema.json
+
+test:
+ @FMT_RED=$$(tput setaf 1); \
+ FMT_BLUE=$$(tput setaf 12); \
+ FMT_CLEAR=$$(tput sgr0); \
+ echo "Running Good Tests"; \
+ for FILE in $$(ls "testdata/good"); do \
+ FILE_PATH="testdata/good/$${FILE}"; \
+ if $(VALIDATE) --schema "$(SCHEMA)" "$${FILE_PATH}" > /dev/null ; then \
+ printf '%s[OK]%s %s\n' "$${FMT_BLUE}" "$${FMT_CLEAR}" "$${FILE_PATH}"; \
+ else \
+ printf '%s[KO]%s %s\n' "$${FMT_RED}" "$${FMT_CLEAR}" "$${FILE_PATH}"; \
+ exit 1; \
+ fi \
+ done; \
+ echo "Running Bad Tests"; \
+ for FILE in $$(ls "testdata/bad"); do \
+ FILE_PATH="testdata/bad/$${FILE}"; \
+ if $(VALIDATE) --schema "$(SCHEMA)" "$${FILE_PATH}" > /dev/null ; then \
+ printf '%s[KO]%s %s\n' "$${FMT_RED}" "$${FMT_CLEAR}" "$${FILE_PATH}"; \
+ exit 1; \
+ else \
+ printf '%s[OK]%s %s\n' "$${FMT_BLUE}" "$${FMT_CLEAR}" "$${FILE_PATH}"; \
+ fi \
+ done
diff --git a/schema/defs.json b/schema/defs.json
new file mode 100644
index 0000000..012de1a
--- /dev/null
+++ b/schema/defs.json
@@ -0,0 +1,144 @@
+{
+ "description": "Definitions used throughout the Container Device Interface Specification",
+ "definitions": {
+ "uint32": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 4294967295
+ },
+ "int64": {
+ "type": "integer",
+ "minimum": -9223372036854775808,
+ "maximum": 9223372036854775807
+ },
+ "ArrayOfStrings": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "FilePath": {
+ "type": "string"
+ },
+ "Env": {
+ "$ref": "#/definitions/ArrayOfStrings"
+ },
+ "mapStringString": {
+ "type": "object",
+ "patternProperties": {
+ ".{1,}": {
+ "type": "string"
+ }
+ }
+ },
+ "DeviceNode": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "$ref": "#/definitions/FilePath"
+ },
+ "hostPath": {
+ "$ref": "#/definitions/FilePath"
+ },
+ "permissions": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ },
+ "major": {
+ "$ref": "#/definitions/int64"
+ },
+ "minor": {
+ "$ref": "#/definitions/int64"
+ },
+ "uid": {
+ "$ref": "#/definitions/uint32"
+ },
+ "gid": {
+ "$ref": "#/definitions/uint32"
+ }
+ },
+ "required": [
+ "path"
+ ]
+ },
+ "Mount": {
+ "type": "object",
+ "properties": {
+ "hostPath": {
+ "$ref": "#/definitions/FilePath"
+ },
+ "containerPath": {
+ "$ref": "#/definitions/FilePath"
+ },
+ "options": {
+ "$ref": "#/definitions/ArrayOfStrings"
+ },
+ "type": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "hostPath",
+ "containerPath"
+ ]
+ },
+ "Hook": {
+ "type": "object",
+ "properties": {
+ "hookName": {
+ "type": "string"
+ },
+ "path": {
+ "$ref": "#/definitions/FilePath"
+ },
+ "args": {
+ "$ref": "#/definitions/ArrayOfStrings"
+ },
+ "env": {
+ "$ref": "#/definitions/ArrayOfStrings"
+ },
+ "timeout": {
+ "$ref": "#/definitions/uint32"
+ }
+ },
+ "required": [
+ "hookName",
+ "path"
+ ]
+ },
+ "containerEdits": {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "array",
+ "items": {
+ "ref": "#definitions/Env"
+ }
+ },
+ "deviceNodes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DeviceNode"
+ }
+ },
+ "mounts": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Mount"
+ }
+ },
+ "hooks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Hook"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "$ref": "#/definitions/mapStringString"
+ }
+ }
+}
diff --git a/schema/schema.go b/schema/schema.go
new file mode 100644
index 0000000..0daf378
--- /dev/null
+++ b/schema/schema.go
@@ -0,0 +1,351 @@
+/*
+ Copyright © 2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package schema
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "sigs.k8s.io/yaml"
+
+ schema "github.com/xeipuuv/gojsonschema"
+ "tags.cncf.io/container-device-interface/internal/multierror"
+ "tags.cncf.io/container-device-interface/internal/validation"
+)
+
+const (
+ // BuiltinSchemaName names the builtin schema for Load()/Set().
+ BuiltinSchemaName = "builtin"
+ // NoneSchemaName names the NOP-schema for Load()/Set().
+ NoneSchemaName = "none"
+ // builtinSchemaFile is the builtin schema URI in our embedded FS.
+ builtinSchemaFile = "file:///schema.json"
+)
+
+// Schema is a JSON validation schema.
+type Schema struct {
+ schema *schema.Schema
+}
+
+// Error wraps a JSON validation result.
+type Error struct {
+ Result *schema.Result
+}
+
+// Set sets the default validating JSON schema.
+func Set(s *Schema) {
+ current = s
+}
+
+// Get returns the active validating JSON schema.
+func Get() *Schema {
+ return current
+}
+
+// BuiltinSchema returns the builtin schema if we have a valid one. Otherwise
+// it falls back to NopSchema().
+func BuiltinSchema() *Schema {
+ if builtin != nil {
+ return builtin
+ }
+
+ s, err := schema.NewSchema(
+ schema.NewReferenceLoaderFileSystem(
+ builtinSchemaFile,
+ http.FS(builtinFS),
+ ),
+ )
+
+ if err == nil {
+ builtin = &Schema{schema: s}
+ } else {
+ builtin = NopSchema()
+ }
+
+ return builtin
+}
+
+// NopSchema returns an validating JSON Schema that does no real validation.
+func NopSchema() *Schema {
+ return &Schema{}
+}
+
+// ReadAndValidate all data from the given reader, using the active schema for validation.
+func ReadAndValidate(r io.Reader) ([]byte, error) {
+ return current.ReadAndValidate(r)
+}
+
+// Validate validates the data read from an io.Reader against the active schema.
+func Validate(r io.Reader) error {
+ return current.Validate(r)
+}
+
+// ValidateData validates the given JSON document against the active schema.
+func ValidateData(data []byte) error {
+ return current.ValidateData(data)
+}
+
+// ValidateFile validates the given JSON file against the active schema.
+func ValidateFile(path string) error {
+ return current.ValidateFile(path)
+}
+
+// ValidateType validates a go object against the schema.
+func ValidateType(obj interface{}) error {
+ return current.ValidateType(obj)
+}
+
+// Load the given JSON Schema.
+func Load(source string) (*Schema, error) {
+ var (
+ loader schema.JSONLoader
+ err error
+ s *schema.Schema
+ )
+
+ source = strings.TrimSpace(source)
+
+ switch {
+ case source == BuiltinSchemaName:
+ return BuiltinSchema(), nil
+ case source == NoneSchemaName, source == "":
+ return NopSchema(), nil
+ case strings.HasPrefix(source, "file://"):
+ case strings.HasPrefix(source, "http://"):
+ case strings.HasPrefix(source, "https://"):
+ default:
+ if strings.Index(source, "://") < 0 {
+ source, err = filepath.Abs(source)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get JSON schema absolute path for %s: %w",
+ source, err)
+ }
+ source = "file://" + source
+ }
+ }
+
+ loader = schema.NewReferenceLoader(source)
+
+ s, err = schema.NewSchema(loader)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load JSON schema: %w", err)
+ }
+
+ return &Schema{schema: s}, nil
+}
+
+// ReadAndValidate all data from the given reader, using the schema for validation.
+func (s *Schema) ReadAndValidate(r io.Reader) ([]byte, error) {
+ loader, reader := schema.NewReaderLoader(r)
+ data, err := ioutil.ReadAll(reader)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read data for validation: %w", err)
+ }
+ return data, s.validate(loader)
+}
+
+// Validate validates the data read from an io.Reader against the schema.
+func (s *Schema) Validate(r io.Reader) error {
+ _, err := s.ReadAndValidate(r)
+ return err
+}
+
+// ValidateData validates the given JSON data against the schema.
+func (s *Schema) ValidateData(data []byte) error {
+ var (
+ any map[string]interface{}
+ err error
+ )
+
+ if !bytes.HasPrefix(bytes.TrimSpace(data), []byte{'{'}) {
+ err = yaml.Unmarshal(data, &any)
+ if err != nil {
+ return fmt.Errorf("failed to YAML unmarshal data for validation: %w", err)
+ }
+ data, err = json.Marshal(any)
+ if err != nil {
+ return fmt.Errorf("failed to JSON remarshal data for validation: %w", err)
+ }
+ }
+
+ if err := s.validate(schema.NewBytesLoader(data)); err != nil {
+ return err
+ }
+
+ return s.validateContents(any)
+}
+
+// ValidateFile validates the given JSON file against the schema.
+func (s *Schema) ValidateFile(path string) error {
+ if filepath.Ext(path) == ".json" {
+ return s.validate(schema.NewReferenceLoader("file://" + path))
+ }
+
+ data, err := ioutil.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return s.ValidateData(data)
+}
+
+// ValidateType validates a go object against the schema.
+func (s *Schema) ValidateType(obj interface{}) error {
+ l := schema.NewGoLoader(obj)
+ return s.validate(l)
+}
+
+// Validate the (to be) loaded doc against the schema.
+func (s *Schema) validate(doc schema.JSONLoader) error {
+ if s == nil || s.schema == nil {
+ return nil
+ }
+
+ docErr, jsonErr := s.schema.Validate(doc)
+ if jsonErr != nil {
+ return fmt.Errorf("failed to load JSON data for validation: %w", jsonErr)
+ }
+ if docErr.Valid() {
+ return nil
+ }
+
+ return &Error{Result: docErr}
+}
+
+type schemaContents map[string]interface{}
+
+func asSchemaContents(i interface{}) (schemaContents, error) {
+ if i == nil {
+ return nil, nil
+ }
+
+ if c, ok := i.(map[string]interface{}); ok {
+ return schemaContents(c), nil
+ }
+
+ return nil, fmt.Errorf("expected map[string]interface{} but got %T", i)
+}
+
+func (c schemaContents) getFieldAsString(key string) (string, bool) {
+ if c == nil {
+ return "", false
+ }
+ if value, ok := c[key]; ok {
+ if value, ok := value.(string); ok {
+ return value, true
+ }
+ }
+ return "", false
+}
+
+func (c schemaContents) getAnnotations() (map[string]interface{}, bool) {
+ if c == nil {
+ return nil, false
+ }
+ if annotations, ok := c["annotations"]; ok {
+ if annotations, ok := annotations.(map[string]interface{}); ok {
+ return annotations, true
+ }
+ }
+ return nil, false
+}
+
+func (c schemaContents) getDevices() ([]schemaContents, error) {
+ if c == nil {
+ return nil, nil
+ }
+ devicesIfc, ok := c["devices"]
+ if !ok {
+ return nil, nil
+ }
+
+ devices, ok := devicesIfc.([]interface{})
+ if !ok {
+ return nil, nil
+ }
+
+ var deviceContents []schemaContents
+ for _, device := range devices {
+ c, err := asSchemaContents(device)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse device: %w", err)
+ }
+ deviceContents = append(deviceContents, c)
+ }
+
+ return deviceContents, nil
+}
+
+// validateContents performs additional validation against the schema contents.
+func (s *Schema) validateContents(any map[string]interface{}) error {
+ if any == nil || s == nil {
+ return nil
+ }
+
+ contents := schemaContents(any)
+
+ if specAnnotations, ok := contents.getAnnotations(); ok {
+ if err := validation.ValidateSpecAnnotations("", specAnnotations); err != nil {
+ return err
+ }
+ }
+
+ devices, err := contents.getDevices()
+ if err != nil {
+ return err
+ }
+
+ for _, device := range devices {
+ name, _ := device.getFieldAsString("name")
+ if annotations, ok := device.getAnnotations(); ok {
+ if err := validation.ValidateSpecAnnotations(name, annotations); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// Error returns the given Result's errors as a single error string.
+func (e *Error) Error() string {
+ if e == nil || e.Result == nil || e.Result.Valid() {
+ return ""
+ }
+
+ var multi error
+ for _, err := range e.Result.Errors() {
+ multi = multierror.Append(multi, fmt.Errorf("%v", err))
+ }
+ return multi.Error()
+}
+
+var (
+ // our builtin schema
+ builtin *Schema
+ // currently loaded schema, builtin by default
+ current = BuiltinSchema()
+)
+
+//go:embed *.json
+var builtinFS embed.FS
diff --git a/schema/schema.json b/schema/schema.json
new file mode 100644
index 0000000..a8e57cc
--- /dev/null
+++ b/schema/schema.json
@@ -0,0 +1,45 @@
+{
+ "description": "Configuration Schema for the Container Device Interface",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "cdiVersion": {
+ "description": "The version of the Container Device Interface Specification that the document complies with",
+ "type": "string"
+ },
+ "kind": {
+ "description": "The kind of the device usually of the form 'vendor.com/device'",
+ "type": "string"
+ },
+ "annotations": {
+ "$ref": "defs.json#/definitions/annotations"
+ },
+ "devices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "The name of the device",
+ "type": "string"
+ },
+ "annotations": {
+ "$ref": "defs.json#/definitions/annotations"
+ },
+ "containerEdits": {
+ "$ref": "defs.json#/definitions/containerEdits"
+ }
+ },
+ "required": [
+ "name",
+ "containerEdits"
+ ]
+ }
+ }
+ },
+ "required": [
+ "cdiVersion",
+ "kind",
+ "devices"
+ ]
+}
diff --git a/schema/schema_test.go b/schema/schema_test.go
new file mode 100644
index 0000000..7030605
--- /dev/null
+++ b/schema/schema_test.go
@@ -0,0 +1,377 @@
+/*
+ Copyright © 2022 The CDI Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package schema_test
+
+import (
+ "bytes"
+ "io"
+ "io/fs"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "tags.cncf.io/container-device-interface/pkg/cdi"
+ "tags.cncf.io/container-device-interface/schema"
+)
+
+var (
+ unloadable = map[string]bool{
+ "empty.json": true,
+ }
+
+ none = schema.NopSchema()
+)
+
+func TestLoad(t *testing.T) {
+ type testCase struct {
+ testName string
+ schemaName string
+ }
+ for _, tc := range []*testCase{
+ {
+ testName: "builtin schema",
+ schemaName: "builtin",
+ },
+ {
+ testName: "externally loaded schema.json",
+ schemaName: "file://./schema.json",
+ },
+ {
+ testName: "disabled/none schema",
+ schemaName: "none",
+ },
+ } {
+ t.Run(tc.testName, func(t *testing.T) {
+ scm, err := schema.Load(tc.schemaName)
+ require.NoError(t, err)
+ require.NotNil(t, scm)
+ })
+ }
+}
+
+func TestValidateFile(t *testing.T) {
+ type testCase struct {
+ testName string
+ schemaName string
+ }
+ for _, tc := range []*testCase{
+ {
+ testName: "builtin schema",
+ schemaName: "builtin",
+ },
+ {
+ testName: "externally loaded schema.json",
+ schemaName: "file://./schema.json",
+ },
+ {
+ testName: "disabled/none schema",
+ schemaName: "none",
+ },
+ } {
+ t.Run(tc.testName, func(t *testing.T) {
+ scm := loadSchema(t, tc.schemaName)
+
+ scanAndValidate(t, scm, "./testdata/good", true, validateFile)
+ scanAndValidate(t, scm, "./testdata/bad", false, validateFile)
+ old := schema.Get()
+ schema.Set(scm)
+ scanAndValidate(t, nil, "./testdata/good", true, validateFile)
+ scanAndValidate(t, nil, "./testdata/bad", false, validateFile)
+ schema.Set(old)
+ })
+ }
+}
+
+func TestValidateData(t *testing.T) {
+ type testCase struct {
+ testName string
+ schemaName string
+ }
+ for _, tc := range []*testCase{
+ {
+ testName: "builtin schema",
+ schemaName: "builtin",
+ },
+ {
+ testName: "externally loaded schema.json",
+ schemaName: "file://./schema.json",
+ },
+ {
+ testName: "disabled/none schema",
+ schemaName: "none",
+ },
+ } {
+ t.Run(tc.testName, func(t *testing.T) {
+ scm := loadSchema(t, tc.schemaName)
+
+ scanAndValidate(t, scm, "./testdata/good", true, validateData)
+ scanAndValidate(t, scm, "./testdata/bad", false, validateData)
+ old := schema.Get()
+ schema.Set(scm)
+ scanAndValidate(t, nil, "./testdata/good", true, validateData)
+ scanAndValidate(t, nil, "./testdata/bad", false, validateData)
+ schema.Set(old)
+ })
+ }
+}
+
+func TestValidateReader(t *testing.T) {
+ type testCase struct {
+ testName string
+ schemaName string
+ }
+ for _, tc := range []*testCase{
+ {
+ testName: "builtin schema",
+ schemaName: "builtin",
+ },
+ {
+ testName: "externally loaded schema.json",
+ schemaName: "file://./schema.json",
+ },
+ {
+ testName: "disabled/none schema",
+ schemaName: "none",
+ },
+ } {
+ t.Run(tc.testName, func(t *testing.T) {
+ scm := loadSchema(t, tc.schemaName)
+
+ scanAndValidate(t, scm, "./testdata/good", true, validateRead)
+ scanAndValidate(t, scm, "./testdata/bad", false, validateRead)
+ old := schema.Get()
+ schema.Set(scm)
+ scanAndValidate(t, nil, "./testdata/good", true, validateRead)
+ scanAndValidate(t, nil, "./testdata/bad", false, validateRead)
+ schema.Set(old)
+ })
+ }
+}
+
+func TestValidateReadAndValidate(t *testing.T) {
+ type testCase struct {
+ testName string
+ schemaName string
+ }
+ for _, tc := range []*testCase{
+ {
+ testName: "builtin schema",
+ schemaName: "builtin",
+ },
+ {
+ testName: "externally loaded schema.json",
+ schemaName: "file://./schema.json",
+ },
+ {
+ testName: "disabled/none schema",
+ schemaName: "none",
+ },
+ } {
+
+ t.Run(tc.testName, func(t *testing.T) {
+ scm := loadSchema(t, tc.schemaName)
+
+ scanAndValidate(t, scm, "./testdata/good", true, readAndValidate)
+ scanAndValidate(t, scm, "./testdata/bad", false, readAndValidate)
+ old := schema.Get()
+ schema.Set(scm)
+ scanAndValidate(t, nil, "./testdata/good", true, readAndValidate)
+ scanAndValidate(t, nil, "./testdata/bad", false, readAndValidate)
+ schema.Set(old)
+ })
+ }
+}
+
+func TestValidateSpec(t *testing.T) {
+ type testCase struct {
+ testName string
+ schemaName string
+ }
+ for _, tc := range []*testCase{
+ {
+ testName: "builtin schema",
+ schemaName: "builtin",
+ },
+ {
+ testName: "externally loaded schema.json",
+ schemaName: "file://./schema.json",
+ },
+ {
+ testName: "disabled/none schema",
+ schemaName: "none",
+ },
+ } {
+ t.Run(tc.testName, func(t *testing.T) {
+ scm := loadSchema(t, tc.schemaName)
+
+ scanAndValidate(t, scm, "./testdata/good", true, validateSpec)
+ scanAndValidate(t, scm, "./testdata/bad", false, validateSpec)
+ old := schema.Get()
+ schema.Set(scm)
+ scanAndValidate(t, nil, "./testdata/good", true, validateSpec)
+ scanAndValidate(t, nil, "./testdata/bad", false, validateSpec)
+ schema.Set(old)
+ })
+ }
+}
+
+func scanAndValidate(t *testing.T, scm *schema.Schema, dir string, isValid bool,
+ validateFn func(t *testing.T, scm *schema.Schema, path string, shouldLoad, isValid bool)) {
+ err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ if path == dir {
+ return nil
+ }
+ scanAndValidate(t, scm, path, isValid, validateFn)
+ } else {
+ if name := info.Name(); filepath.Ext(name) != ".json" || name == "empty.json" {
+ return nil
+ }
+ //fmt.Printf("*** processing %s...\n", path)
+ validateFn(t, scm, path, !unloadable[filepath.Base(path)], isValid)
+ }
+
+ return nil
+ })
+ require.NoError(t, err)
+}
+
+func validateFile(t *testing.T, scm *schema.Schema, path string, shouldLoad, isValid bool) {
+ var err error
+
+ if scm != nil {
+ err = scm.ValidateFile(path)
+ } else {
+ err = schema.ValidateFile(path)
+ }
+
+ verifyResult(t, scm, err, shouldLoad, isValid)
+}
+
+func validateData(t *testing.T, scm *schema.Schema, path string, shouldLoad, isValid bool) {
+ data, err := ioutil.ReadFile(path)
+ require.NoError(t, err)
+
+ if scm != nil {
+ err = scm.ValidateData(data)
+ } else {
+ err = schema.ValidateData(data)
+ }
+
+ verifyResult(t, scm, err, shouldLoad, isValid)
+}
+
+func readAndValidate(t *testing.T, scm *schema.Schema, path string, shouldLoad, isValid bool) {
+ var (
+ data []byte
+ )
+
+ f, err := os.Open(path)
+ defer f.Close()
+ require.NoError(t, err)
+
+ if scm != nil {
+ data, err = scm.ReadAndValidate(f)
+ } else {
+ data, err = schema.ReadAndValidate(f)
+ }
+
+ verifyResult(t, scm, err, shouldLoad, isValid)
+
+ if scm != nil {
+ err = scm.Validate(bytes.NewReader(data))
+ } else {
+ err = schema.Validate(bytes.NewReader(data))
+ }
+
+ verifyResult(t, scm, err, shouldLoad, isValid)
+}
+
+func validateRead(t *testing.T, scm *schema.Schema, path string, shouldLoad, isValid bool) {
+ f, err := os.Open(path)
+ defer f.Close()
+ require.NoError(t, err)
+
+ buf := &bytes.Buffer{}
+ r := io.TeeReader(f, buf)
+
+ if scm != nil {
+ err = scm.Validate(r)
+ } else {
+ err = schema.Validate(r)
+ }
+
+ verifyResult(t, scm, err, shouldLoad, isValid)
+
+ if scm != nil {
+ err = scm.Validate(bytes.NewReader(buf.Bytes()))
+ } else {
+ err = schema.Validate(bytes.NewReader(buf.Bytes()))
+ }
+
+ verifyResult(t, scm, err, shouldLoad, isValid)
+}
+
+func validateSpec(t *testing.T, scm *schema.Schema, path string, shouldLoad, isValid bool) {
+ var old *schema.Schema
+
+ if scm != nil {
+ old = schema.Get()
+ schema.Set(scm)
+ }
+ spec, err := cdi.ReadSpec(path, 0)
+ if scm != nil {
+ schema.Set(old)
+ }
+
+ if !shouldLoad || !isValid {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, spec)
+ }
+}
+
+func loadSchema(t *testing.T, name string) *schema.Schema {
+ if name == schema.NoneSchemaName {
+ return none
+ }
+
+ scm, err := schema.Load(name)
+ require.NoError(t, err)
+ require.NotNil(t, scm)
+ return scm
+}
+
+func verifyResult(t *testing.T, s *schema.Schema, err error, shouldLoad, isValid bool) {
+ if s == none || (s == nil && schema.Get() == none) {
+ require.NoError(t, err)
+ return
+ }
+
+ if !isValid || !shouldLoad {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+}
diff --git a/schema/testdata/bad/empty.json b/schema/testdata/bad/empty.json
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/schema/testdata/bad/empty.json
diff --git a/schema/testdata/bad/explicit-empty.json b/schema/testdata/bad/explicit-empty.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/schema/testdata/bad/explicit-empty.json
@@ -0,0 +1 @@
+{}
diff --git a/schema/testdata/good/minimal.json b/schema/testdata/good/minimal.json
new file mode 100644
index 0000000..7388fc9
--- /dev/null
+++ b/schema/testdata/good/minimal.json
@@ -0,0 +1,12 @@
+{
+ "cdiVersion": "0.3.0",
+ "kind": "vendor.com/device",
+ "devices": [
+ {
+ "name": "myDevice",
+ "containerEdits": {
+ "deviceNodes": [{"path": "/dev/card1"}]
+ }
+ }
+ ]
+}
diff --git a/schema/testdata/good/spec-example.json b/schema/testdata/good/spec-example.json
new file mode 100644
index 0000000..765a06d
--- /dev/null
+++ b/schema/testdata/good/spec-example.json
@@ -0,0 +1,32 @@
+{
+ "cdiVersion": "0.6.0",
+ "kind": "vendor.com/device",
+ "devices": [
+ {
+ "name": "myDevice",
+ "containerEdits": {
+ "deviceNodes": [
+ {"hostPath": "/vendorroot/dev/card1", "path": "/dev/card1"},
+ {"path": "/dev/card2"}
+ ]
+ }
+ }
+ ],
+ "containerEdits": {
+ "env": [
+ "FOO=CDI_SPEC",
+ "BAR=BARVALUE1"
+ ],
+ "deviceNodes": [
+ {"path": "/dev/vendorctl", "uid": 1000, "gid": 1000}
+ ],
+ "mounts": [
+ {"hostPath": "/bin/vendorBin", "containerPath": "/bin/vendorBin"},
+ {"hostPath": "/usr/lib/libVendor.so.0", "containerPath": "/usr/lib/libVendor.so.0"}
+ ],
+ "hooks": [
+ {"hookName": "createContainer", "path": "/bin/vendor-hook"},
+ {"hookName": "startContainer", "path": "/usr/bin/ldconfig"}
+ ]
+ }
+}
diff --git a/specs-go/config.go b/specs-go/config.go
new file mode 100644
index 0000000..4043b85
--- /dev/null
+++ b/specs-go/config.go
@@ -0,0 +1,62 @@
+package specs
+
+import "os"
+
+// CurrentVersion is the current version of the Spec.
+const CurrentVersion = "0.6.0"
+
+// Spec is the base configuration for CDI
+type Spec struct {
+ Version string `json:"cdiVersion"`
+ Kind string `json:"kind"`
+ // Annotations add meta information per CDI spec. Note these are CDI-specific and do not affect container metadata.
+ Annotations map[string]string `json:"annotations,omitempty"`
+ Devices []Device `json:"devices"`
+ ContainerEdits ContainerEdits `json:"containerEdits,omitempty"`
+}
+
+// Device is a "Device" a container runtime can add to a container
+type Device struct {
+ Name string `json:"name"`
+ // Annotations add meta information per device. Note these are CDI-specific and do not affect container metadata.
+ Annotations map[string]string `json:"annotations,omitempty"`
+ ContainerEdits ContainerEdits `json:"containerEdits"`
+}
+
+// ContainerEdits are edits a container runtime must make to the OCI spec to expose the device.
+type ContainerEdits struct {
+ Env []string `json:"env,omitempty"`
+ DeviceNodes []*DeviceNode `json:"deviceNodes,omitempty"`
+ Hooks []*Hook `json:"hooks,omitempty"`
+ Mounts []*Mount `json:"mounts,omitempty"`
+}
+
+// DeviceNode represents a device node that needs to be added to the OCI spec.
+type DeviceNode struct {
+ Path string `json:"path"`
+ HostPath string `json:"hostPath,omitempty"`
+ Type string `json:"type,omitempty"`
+ Major int64 `json:"major,omitempty"`
+ Minor int64 `json:"minor,omitempty"`
+ FileMode *os.FileMode `json:"fileMode,omitempty"`
+ Permissions string `json:"permissions,omitempty"`
+ UID *uint32 `json:"uid,omitempty"`
+ GID *uint32 `json:"gid,omitempty"`
+}
+
+// Mount represents a mount that needs to be added to the OCI spec.
+type Mount struct {
+ HostPath string `json:"hostPath"`
+ ContainerPath string `json:"containerPath"`
+ Options []string `json:"options,omitempty"`
+ Type string `json:"type,omitempty"`
+}
+
+// Hook represents a hook that needs to be added to the OCI spec.
+type Hook struct {
+ HookName string `json:"hookName"`
+ Path string `json:"path"`
+ Args []string `json:"args,omitempty"`
+ Env []string `json:"env,omitempty"`
+ Timeout *int `json:"timeout,omitempty"`
+}
diff --git a/specs-go/go.mod b/specs-go/go.mod
new file mode 100644
index 0000000..072646a
--- /dev/null
+++ b/specs-go/go.mod
@@ -0,0 +1,5 @@
+module tags.cncf.io/container-device-interface/specs-go
+
+go 1.19
+
+require github.com/opencontainers/runtime-spec v1.0.3-0.20220909204839-494a5a6aca78
diff --git a/specs-go/go.sum b/specs-go/go.sum
new file mode 100644
index 0000000..5b4780f
--- /dev/null
+++ b/specs-go/go.sum
@@ -0,0 +1,2 @@
+github.com/opencontainers/runtime-spec v1.0.3-0.20220909204839-494a5a6aca78 h1:R5M2qXZiK/mWPMT4VldCOiSL9HIAMuxQZWdG0CSM5+4=
+github.com/opencontainers/runtime-spec v1.0.3-0.20220909204839-494a5a6aca78/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
diff --git a/specs-go/oci.go b/specs-go/oci.go
new file mode 100644
index 0000000..229ad52
--- /dev/null
+++ b/specs-go/oci.go
@@ -0,0 +1,38 @@
+package specs
+
+import (
+ spec "github.com/opencontainers/runtime-spec/specs-go"
+)
+
+// ToOCI returns the opencontainers runtime Spec Hook for this Hook.
+func (h *Hook) ToOCI() spec.Hook {
+ return spec.Hook{
+ Path: h.Path,
+ Args: h.Args,
+ Env: h.Env,
+ Timeout: h.Timeout,
+ }
+}
+
+// ToOCI returns the opencontainers runtime Spec Mount for this Mount.
+func (m *Mount) ToOCI() spec.Mount {
+ return spec.Mount{
+ Source: m.HostPath,
+ Destination: m.ContainerPath,
+ Options: m.Options,
+ Type: m.Type,
+ }
+}
+
+// ToOCI returns the opencontainers runtime Spec LinuxDevice for this DeviceNode.
+func (d *DeviceNode) ToOCI() spec.LinuxDevice {
+ return spec.LinuxDevice{
+ Path: d.Path,
+ Type: d.Type,
+ Major: d.Major,
+ Minor: d.Minor,
+ FileMode: d.FileMode,
+ UID: d.UID,
+ GID: d.GID,
+ }
+}