diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..5b627cfa --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,4 @@ +## Code of Conduct +This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). +For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact +opensource-codeofconduct@amazon.com with any additional questions or comments. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c4b6a1c5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing Guidelines + +Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional +documentation, we greatly value feedback and contributions from our community. + +Please read through this document before submitting any issues or pull requests to ensure we have all the necessary +information to effectively respond to your bug report or contribution. + + +## Reporting Bugs/Feature Requests + +We welcome you to use the GitHub issue tracker to report bugs or suggest features. + +When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already +reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: + +* A reproducible test case or series of steps +* The version of our code being used +* Any modifications you've made relevant to the bug +* Anything unusual about your environment or deployment + + +## Contributing via Pull Requests +Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: + +1. You are working against the latest source on the *main* branch. +2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. +3. You open an issue to discuss any significant work - we would hate for your time to be wasted. + +To send us a pull request, please: + +1. Fork the repository. +2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. +3. Ensure local tests pass. +4. Commit to your fork using clear commit messages. +5. Send us a pull request, answering any default questions in the pull request interface. +6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. + +GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and +[creating a pull request](https://help.github.com/articles/creating-a-pull-request/). + + +## Finding contributions to work on +Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. + + +## Code of Conduct +This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). +For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact +opensource-codeofconduct@amazon.com with any additional questions or comments. + + +## Security issue notifications +If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. + + +## Licensing + +See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..67db8588 --- /dev/null +++ b/LICENSE @@ -0,0 +1,175 @@ + + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..6c05c154 --- /dev/null +++ b/Makefile @@ -0,0 +1,93 @@ +GINKGO = go run github.com/onsi/ginkgo/v2/ginkgo +# Common ginkgo options: -v for verbose mode, --focus="test name" for running single tests +GFLAGS ?= --race --randomize-all --randomize-suites +BIN = $(PWD)/bin +FINCH_ROOT ?= /Applications/Finch + +# Linux or macOS targets +.PHONY: build +build:: + $(eval PACKAGE := github.com/runfinch/finch-daemon) + $(eval VERSION ?= $(shell git describe --match 'v[0-9]*' --dirty='.modified' --always --tags)) + $(eval GITCOMMIT := $(shell git rev-parse HEAD)$(shell if ! git diff --no-ext-diff --quiet --exit-code; then echo .m; fi)) + $(eval LDFLAGS := "-X $(PACKAGE)/pkg/version.Version=$(VERSION) -X $(PACKAGE)/pkg/version.GitCommit=$(GITCOMMIT)") + GOOS=linux go build -ldflags $(LDFLAGS) -v -o bin/finch-daemon $(PACKAGE)/cmd/finch-daemon + +# Linux targets +.PHONY: linux +linux: +ifneq ($(shell uname), Linux) + $(error This needs to be run on linux!) +endif + +.PHONY: start +start: linux build unlink + sudo bin/finch-daemon --debug --socket-owner $${UID} + +DLV=$(BIN)/dlv +$(DLV): + GOBIN=$(BIN) go install github.com/go-delve/delve/cmd/dlv@latest + +.PHONY: start-debug +start-debug: linux build $(DLV) unlink + sudo $(DLV) --listen=:2345 --headless=true --api-version=2 exec ./bin/finch-daemon -- --debug --socket-owner $${UID} + +# Unlink the unix socket if the link does not get cleaned up properly (or if finch-daemon is already running) +.PHONY: unlink +unlink: linux +ifneq ("$(wildcard /run/finch.sock)","") + sudo unlink /run/finch.sock +endif + +.PHONY: code-gen +code-gen: linux + rm -rf ./pkg/mocks + GOBIN=$(BIN) go install github.com/golang/mock/mockgen + GOBIN=$(BIN) go install golang.org/x/tools/cmd/stringer + PATH=$(BIN):$(PATH) go generate ./... + PATH=$(BIN):$(PATH) mockgen --destination=./pkg/mocks/mocks_container/container.go -package=mocks_container github.com/containerd/containerd Container + PATH=$(BIN):$(PATH) mockgen --destination=./pkg/mocks/mocks_container/process.go -package=mocks_container github.com/containerd/containerd Process + PATH=$(BIN):$(PATH) mockgen --destination=./pkg/mocks/mocks_container/task.go -package=mocks_container github.com/containerd/containerd Task + PATH=$(BIN):$(PATH) mockgen --destination=./pkg/mocks/mocks_image/store.go -package=mocks_image github.com/containerd/containerd/images Store + PATH=$(BIN):$(PATH) mockgen --destination=./pkg/mocks/mocks_container/network_manager.go -package=mocks_container github.com/containerd/nerdctl/pkg/containerutil NetworkOptionsManager + PATH=$(BIN):$(PATH) mockgen --destination=./pkg/mocks/mocks_cio/io.go -package=mocks_cio github.com/containerd/containerd/cio IO + PATH=$(BIN):$(PATH) mockgen --destination=./pkg/mocks/mocks_http/response_writer.go -package=mocks_http net/http ResponseWriter + PATH=$(BIN):$(PATH) mockgen --destination=./pkg/mocks/mocks_http/conn.go -package=mocks_http net Conn + +GOLINT=$(BIN)/golangci-lint +$(GOLINT): linux + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(BIN) v1.53.3 + +.PHONY: golint +golint: linux $(GOLINT) + $(GOLINT) run ./pkg/... + +.PHONY: run-unit-tests +run-unit-tests: linux + $(GINKGO) $(GFLAGS) ./pkg/... + +# Runs tests in headless dlv mode, must specify package directory with PKG_DIR +PKG_DIR ?= . +.PHONY: debug-unit-tests +debug-unit-tests: linux $(DLV) + sudo $(DLV) --listen=:2345 --headless=true --api-version=2 test $(PKG_DIR) + +.PHONY: coverage +coverage: linux + $(GINKGO) -r -v -race --trace --cover --coverprofile="coverage-report.out" --coverpkg=./... ./pkg/... + go tool cover -html="coverage-report.out" -o="unit-test-coverage-report.html" + +# macOS targets + +.PHONY: macOS +macOS: +ifneq ($(shell uname), Darwin) + $(error This needs to be run on macOS!) +endif + +.PHONY: run-e2e-tests +run-e2e-tests: macOS + DOCKER_HOST="unix://$(FINCH_ROOT)/lima/data/finch/sock/finch.sock" \ + DOCKER_API_VERSION="v1.41" \ + RUN_E2E_TESTS=1 \ + $(GINKGO) $(GFLAGS) ./e2e/... diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..616fc588 --- /dev/null +++ b/NOTICE @@ -0,0 +1 @@ +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/README.md b/README.md new file mode 100644 index 00000000..b7c95a38 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# finch-daemon + +The Finch Daemon project is a container runtime engine that enables users to seamelssly integrate their software which has programmatic dependencies on Docker RESTful APIs. This project currently implements the [Docker API Spec v1.43](https://docs.docker.com/engine/api/v1.43/). + +## Onboarding & Development + +Please review [CONTRIBUTING.md](./CONTRIBUTING.md) for onboarding as well for an overview of the development cycle for this project. Additionally, check the [Makefile](./Makefile) for additional information on setup & configuration. + +## Quickstart + +### macOS +Note that with macOS, it is not possible to run unit tests with `make run-unit-tests` or do code-gen with `make code-gen` directly as this is a Linux project. It is possible, however, to build the project on macOS with `make` and run the linux-related commands in the finch vm. + +1. Get finch, `brew install finch` +2. Add the unix socket forwarding to `/Applications/Finch/lima/data/finch/lima.yaml`: + ```yaml + portForwards: + - guestSocket: "/run/fnich.sock" + hostSocket: "{{.Dir}}/sock/finch.sock" + ``` +3. Init the vm to apply the changes (or restart if Finch was already running): + ```bash + # init + finch vm init + + # restart + finch vm stop + finch vm start + ``` +4. Pull finch-daemon from the GitHub repo to somewhere under your home directory: `git clone https://github.com/runfinch/finch-daemon.git` +5. Check if you have go installed with `go version`, if not, install go with `brew install go` +6. Build finch-daemon with `make` +7. Spin up the finch-daemon server in finch's vm: + ```bash + LIMA_HOME=/Applications/Finch/lima/data /Applications/Finch/lima/bin/limactl shell finch + cd + sudo bin/finch-daemon --debug --socket-owner $UID + ``` +8. Test the project and use finch to confirm changes + - Using curl: + ```bash + curl -v -X GET --unix-socket /Applications/Finch/lima/data/finch/sock/finch.sock 'http://localhost/version' + ``` + - Using Docker CLI: + ```bash + DOCKER_HOST=unix:///Applications/Finch/lima/data/finch/sock/finch.sock docker images + ``` + - Using the Docker python SDK (after installing python3): + ```bash + # Install docker python SDK + python3 -m pip install docker + # Start python + DOCKER_HOST=unix:///Applications/Finch/lima/data/finch/sock/finch.sock python3 + ``` + ```python + >>> import docker + >>> client = docker.from_env() + >>> con = client.containers.get("test-container") + >>> con.attach(stream=True, logs=True, demux=True) + ``` \ No newline at end of file diff --git a/cmd/finch-daemon/main.go b/cmd/finch-daemon/main.go new file mode 100644 index 00000000..a7128632 --- /dev/null +++ b/cmd/finch-daemon/main.go @@ -0,0 +1,185 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/containerd/containerd" + "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/config" + "github.com/coreos/go-systemd/v22/daemon" + "github.com/runfinch/finch-daemon/pkg/api/router" + "github.com/runfinch/finch-daemon/pkg/archive" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/ecc" + "github.com/runfinch/finch-daemon/pkg/flog" + "github.com/runfinch/finch-daemon/pkg/service/builder" + "github.com/runfinch/finch-daemon/pkg/service/container" + "github.com/runfinch/finch-daemon/pkg/service/exec" + "github.com/runfinch/finch-daemon/pkg/service/image" + "github.com/runfinch/finch-daemon/pkg/service/network" + "github.com/runfinch/finch-daemon/pkg/service/system" + "github.com/runfinch/finch-daemon/pkg/service/volume" + "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +const ( + // Keep this value in sync with `guestSocket` in README.md. + defaultFinchAddr = "/run/finch.sock" + defaultNamespace = "finch" +) + +type DaemonOptions struct { + debug bool + socket string + socketOwner int +} + +var options = new(DaemonOptions) + +func main() { + rootCmd := &cobra.Command{ + Use: "finch-daemon", + Short: "Docker Engine API backed by containerd in finch VM", + RunE: runAdapter, + SilenceUsage: true, + } + options.socket = defaultFinchAddr + rootCmd.Flags().BoolVar(&options.debug, "debug", false, "whether to print debug logs") + rootCmd.Flags().IntVar(&options.socketOwner, "socket-owner", -1, "UID and GID of the socket to which finch-daemon will listen."+ + " It's useful when finch-daemon needs to be run as root to access other resources (e.g., rootful containerd socket),"+ + " but the socket has to be owned by the lima user to make port forwarding work"+ + " (more info: https://github.com/lima-vm/lima/blob/5a9bca3d09481ed7109b14f8d3f0074816731f43/examples/default.yaml#L340)."+ + " -1 means no-op.") + if err := rootCmd.Execute(); err != nil { + log.Printf("got error: %v", err) + log.Fatal(err) + } +} + +func runAdapter(cmd *cobra.Command, _ []string) error { + return run(options) +} + +func run(options *DaemonOptions) error { + // This sets the log level of the dependencies that use logrus (e.g., containerd library). + if options.debug { + logrus.SetLevel(logrus.DebugLevel) + } + + logger := flog.NewLogrus() + r, err := newRouter(options.debug, logger) + if err != nil { + return fmt.Errorf("failed to create a router: %w", err) + } + + serverWg := &sync.WaitGroup{} + serverWg.Add(1) + + listener, err := net.Listen("unix", options.socket) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", options.socket, err) + } + // TODO: Revisit this after we use systemd to manage finch-daemon. + // Related: https://github.com/lima-vm/lima/blob/5a9bca3d09481ed7109b14f8d3f0074816731f43/examples/podman-rootful.yaml#L44 + if err := os.Chown(options.socket, options.socketOwner, options.socketOwner); err != nil { + return fmt.Errorf("failed to chown the finch-daemon socket: %w", err) + } + server := &http.Server{ + Handler: r, + } + handleSignal(options.socket, server, logger) + + go func() { + logger.Infof("Serving on %s...", options.socket) + defer serverWg.Done() + // Serve will either exit with an error immediately or return + // http.ErrServerClosed when the server is successfully closed. + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Fatal(err) + } + }() + + sdNotify(daemon.SdNotifyReady, logger) + serverWg.Wait() + logger.Debugln("Server stopped. Exiting...") + return nil +} + +func newRouter(debug bool, logger *flog.Logrus) (http.Handler, error) { + conf := config.New() + conf.Debug = debug + conf.Namespace = defaultNamespace + client, err := containerd.New(conf.Address, containerd.WithDefaultNamespace(conf.Namespace)) + if err != nil { + return nil, fmt.Errorf("failed to create containerd client: %w", err) + } + clientWrapper := backend.NewContainerdClientWrapper(client) + // GlobalCommandOptions is actually just an alias for Config, see + // https://github.com/containerd/nerdctl/blob/9f8655f7722d6e6851755123730436bf1a6c9995/pkg/api/types/global.go#L21 + globalOptions := (*types.GlobalCommandOptions)(conf) + ncWrapper := backend.NewNerdctlWrapper(clientWrapper, globalOptions) + if _, err = ncWrapper.GetNerdctlExe(); err != nil { + return nil, fmt.Errorf("failed to find nerdctl binary: %w", err) + } + fs := afero.NewOsFs() + execCmdCreator := ecc.NewExecCmdCreator() + tarCreator := archive.NewTarCreator(execCmdCreator, logger) + tarExtractor := archive.NewTarExtractor(execCmdCreator, logger) + opts := &router.Options{ + Config: conf, + ContainerService: container.NewService(clientWrapper, ncWrapper, logger, fs, tarCreator, tarExtractor), + ImageService: image.NewService(clientWrapper, ncWrapper, logger), + NetworkService: network.NewService(clientWrapper, ncWrapper, logger), + SystemService: system.NewService(clientWrapper, ncWrapper, logger), + BuilderService: builder.NewService(clientWrapper, ncWrapper, logger, tarExtractor), + VolumeService: volume.NewService(ncWrapper, logger), + ExecService: exec.NewService(clientWrapper, logger), + NerdctlWrapper: ncWrapper, + } + return router.New(opts), nil +} + +func handleSignal(socket string, server *http.Server, logger *flog.Logrus) { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigs + switch sig { + case os.Interrupt: + sdNotify(daemon.SdNotifyStopping, logger) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Fatal(err) + } + case syscall.SIGTERM: + sdNotify(daemon.SdNotifyStopping, logger) + if err := server.Close(); err != nil { + log.Fatal(err) + } + os.Remove(socket) + } + }() +} + +func sdNotify(state string, logger *flog.Logrus) { + // (false, nil) - notification not supported (i.e. NOTIFY_SOCKET is unset) + // (false, err) - notification supported, but failure happened (e.g. error connecting to NOTIFY_SOCKET or while sending data) + // (true, nil) - notification supported, data has been sent + notified, err := daemon.SdNotify(false, state) + logger.Debugf("systemd-notify result: (signaled %t), (err: %v)", notified, err) +} diff --git a/e2e/client/client.go b/e2e/client/client.go new file mode 100644 index 00000000..ff024517 --- /dev/null +++ b/e2e/client/client.go @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package client provides a client for communicating with finch-daemon using unix sockets. +package client + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" +) + +// NewClient creates a new http client that connects to the finch-daemon server +func NewClient(socketPath string) *http.Client { + if socketPath == "" { + panic("socketPath is empty") + } + // remove the prefix unix:// from the socket path + socketPath = strings.TrimPrefix(socketPath, "unix://") + + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + } +} + +func ConvertToFinchUrl(version, relativeUrl string) string { + if version == "" { + return fmt.Sprintf("http://finch%s", relativeUrl) + } + return fmt.Sprintf("http://finch/%s%s", version, relativeUrl) +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 00000000..c0b1e4eb --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "os" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/tests" +) + +func TestRun(t *testing.T) { + if os.Getenv("RUN_E2E_TESTS") != "1" { + t.Skip("E2E tests skipped. Set RUN_E2E_TESTS=1 to run these tests") + } + + finchexe := tests.GetFinchExe() + opt, _ := option.New([]string{finchexe}) + + ginkgo.SynchronizedBeforeSuite(func() []byte { + tests.SetupLocalRegistry(opt) + return nil + }, func(bytes []byte) {}) + + ginkgo.SynchronizedAfterSuite(func() { + tests.CleanupLocalRegistry(opt) + // clean up everything after the local registry is cleaned up + command.RemoveAll(opt) + }, func() {}) + + const description = "Finch Daemon Functional test" + ginkgo.Describe(description, func() { + // functional test for container APIs + tests.ContainerCreate(opt) + tests.ContainerStart(opt) + tests.ContainerStop(opt) + tests.ContainerRemove(opt) + tests.ContainerList(opt) + tests.ContainerRename(opt) + tests.ContainerStats(opt) + tests.ContainerAttach(opt) + tests.ContainerLogs(opt) + + // functional test for volume APIs + tests.VolumeList(opt) + tests.VolumeInspect(opt) + tests.VolumeRemove(opt) + + // functional test for network APIs + tests.NetworkCreate(opt) + tests.NetworkRemove(opt) + tests.NetworkList(opt) + tests.NetworkInspect(opt) + + // functional test for image APIs + tests.ImageRemove(opt) + tests.ImagePush(opt) + tests.ImagePull(opt) + + // functional test for system api + tests.SystemVersion(opt) + tests.SystemEvents(opt) + }) + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, description) +} diff --git a/e2e/tests/container_attach.go b/e2e/tests/container_attach.go new file mode 100644 index 00000000..bf11b3f6 --- /dev/null +++ b/e2e/tests/container_attach.go @@ -0,0 +1,164 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "fmt" + "io" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" +) + +// ContainerAttach tests the `POST containers/attach` API. +func ContainerAttach(opt *option.Option) { + Describe("attach to a container", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + // run container in detached mode, outputting 1, 2, 3 in different lines + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, + "/bin/sh", "-c", `for VAR in 1 2 3; do echo $VAR; done; sleep infinity`) + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + It("should return a 404 status if the container is not found", func() { + // create url and options + notFoundName := "doesnt-exist" + relativeUrl := fmt.Sprintf("/containers/%s/attach", notFoundName) + opts := "?stdin=1" + + "&stdout=1" + + "&stderr=1" + + "&logs=1" + + "&stream=1" + + // call the endpoint + res, err := uClient.Post(client.ConvertToFinchUrl(version, relativeUrl+opts), + "application/json", nil) + Expect(err).Should(BeNil()) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + Expect(string(body)).Should(ContainSubstring(`no such container: ` + notFoundName)) + }) + It("should return successfully and do nothing when logs and stream are false", func() { + // create url and options + relativeUrl := fmt.Sprintf("/containers/%s/attach", testContainerName) + opts := "?stdin=1" + + "&stdout=1" + + "&stderr=1" + + "&logs=0" + + "&stream=0" + + // call the endpoint + res, err := uClient.Post(client.ConvertToFinchUrl(version, relativeUrl+opts), + "application/json", nil) + Expect(err).Should(BeNil()) + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + }) + It("should succeed attaching to a running container, not stream, and read the logs", func() { + // create url and options + relativeUrl := fmt.Sprintf("/containers/%s/attach", testContainerName) + opts := "?stdin=1" + + "&stdout=1" + + "&stderr=1" + + "&logs=1" + + "&stream=0" + + // wait for container to run & echo the output, then call endpoint + time.Sleep(1 * time.Second) + res, err := uClient.Post(client.ConvertToFinchUrl(version, relativeUrl+opts), + "application/json", nil) + Expect(err).Should(BeNil()) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + // response body is made up of the stream format explained here: + // https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerAttach + // basically, header := [8]byte{STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4} + Expect(body[8]).Should(Equal(byte('1'))) + Expect(body[18]).Should(Equal(byte('2'))) + Expect(body[28]).Should(Equal(byte('3'))) + }) + It("should succeed attaching to a running container, reading the logs and stream", func() { + altCtrName := "ctr-test2" + command.Run(opt, "run", "-d", "--name", altCtrName, defaultImage, + "/bin/sh", "-c", `for VAR in 1 2 3; do echo $VAR; done; sleep 2; for VAR in a b c; do echo $VAR; done`) + // create url and options + relativeUrl := fmt.Sprintf("/containers/%s/attach", altCtrName) + opts := "?stdin=1" + + "&stdout=1" + + "&stderr=1" + + "&logs=1" + + "&stream=1" + + // wait for container to reach steady state, then call endpoint + time.Sleep(1 * time.Second) + res, err := uClient.Post(client.ConvertToFinchUrl(version, relativeUrl+opts), + "application/json", nil) + Expect(err).Should(BeNil()) + + time.Sleep(4 * time.Second) + body, err := io.ReadAll(res.Body) + _ = res.Body.Close() + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + // logged responses + Expect(body[8]).Should(Equal(byte('1'))) + Expect(body[18]).Should(Equal(byte('2'))) + Expect(body[28]).Should(Equal(byte('3'))) + // streamed responses + Expect(body[38]).Should(Equal(byte('a'))) + Expect(body[48]).Should(Equal(byte('b'))) + Expect(body[58]).Should(Equal(byte('c'))) + }) + It("should succeed attaching to a running container and read subsequent streams", func() { + altCtrName := "ctr-test2" + command.Run(opt, "run", "-d", "--name", altCtrName, defaultImage, + "/bin/sh", "-c", `for VAR in 1 2 3; do echo $VAR; done; sleep 2; for VAR in a b c; do echo $VAR; done`) + // create url and options + relativeUrl := fmt.Sprintf("/containers/%s/attach", altCtrName) + opts := "?stdin=1" + + "&stdout=1" + + "&stderr=1" + + "&logs=0" + + "&stream=1" + + // wait for container to reach steady state, then call endpoint + time.Sleep(1 * time.Second) + res, err := uClient.Post(client.ConvertToFinchUrl(version, relativeUrl+opts), + "application/json", nil) + Expect(err).Should(BeNil()) + + time.Sleep(4 * time.Second) + body, err := io.ReadAll(res.Body) + _ = res.Body.Close() + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + Expect(body[8]).Should(Equal(byte('a'))) + Expect(body[18]).Should(Equal(byte('b'))) + Expect(body[28]).Should(Equal(byte('c'))) + }) + }) +} diff --git a/e2e/tests/container_create.go b/e2e/tests/container_create.go new file mode 100644 index 00000000..c2995c2f --- /dev/null +++ b/e2e/tests/container_create.go @@ -0,0 +1,441 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/docker/go-connections/nat" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/ffs" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +type containerCreateResponse struct { + ID string `json:"Id"` + Message string `json:"message"` +} + +// ContainerCreate tests the `POST containers/create` API. +func ContainerCreate(opt *option.Option) { + Describe("create container", func() { + var ( + uClient *http.Client + version string + url string + options types.ContainerCreateRequest + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + // set finch daemon request url + url = client.ConvertToFinchUrl(version, "/containers/create") + // set default container options + options = types.ContainerCreateRequest{} + options.Image = defaultImage + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should successfully create a container that prints hello world", func() { + // define options + options.Cmd = []string{"echo", "hello world"} + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify output + out := command.StdoutStr(opt, "start", "-a", testContainerName) + Expect(out).Should(Equal("hello world")) + }) + It("should successfully log container output for the created container", func() { + // define options + options.Cmd = []string{"echo", "hello world"} + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify output + command.Run(opt, "start", testContainerName) + out := command.StdoutStr(opt, "logs", testContainerName) + Expect(out).Should(Equal("hello world")) + }) + It("should fail to create a container with duplicate name", func() { + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // create container with duplicate name + statusCode, ctr = createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusInternalServerError)) + }) + It("should fail to create a container for a nonexistent image", func() { + // define options + options.Image = "non-existent-image" + + // create container + statusCode, _ := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusInternalServerError)) + }) + + // Network Settings + + It("should attach container to the bridge network", func() { + // define options + options.Cmd = []string{"sleep", "Infinity"} + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify network settings + command.Run(opt, "start", testContainerName) + verifyNetworkSettings(opt, testContainerName, "bridge") + }) + It("should attach container to the bridge network for default network mode", func() { + // define options + options.Cmd = []string{"sleep", "Infinity"} + options.HostConfig.NetworkMode = "default" + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify network settings + command.Run(opt, "start", testContainerName) + verifyNetworkSettings(opt, testContainerName, "bridge") + }) + It("should attach container to the specified network using network name", func() { + // define options + options.Cmd = []string{"sleep", "Infinity"} + options.HostConfig.NetworkMode = testNetwork + + // create network + command.Run(opt, "network", "create", testNetwork) + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify network settings + command.Run(opt, "start", testContainerName) + verifyNetworkSettings(opt, testContainerName, testNetwork) + }) + It("should attach container to the specified network using network id", func() { + // create network + netId := command.StdoutStr(opt, "network", "create", testNetwork) + Expect(netId).ShouldNot(BeEmpty()) + + // define options + options.Cmd = []string{"sleep", "Infinity"} + options.HostConfig.NetworkMode = netId + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify network settings + command.Run(opt, "start", testContainerName) + verifyNetworkSettings(opt, testContainerName, testNetwork) + }) + It("should create a container with specified port mappings", func() { + hostPort := "8001" + ctrPort := "8000" + hostPort2 := "9001" + ctrPort2 := "9000" + + // define options + tcpPort := nat.Port(fmt.Sprintf("%s/tcp", ctrPort)) + tcpPortBinding := nat.PortBinding{HostIP: "0.0.0.0", HostPort: hostPort} + udpPort := nat.Port(fmt.Sprintf("%s/udp", ctrPort2)) + udpPortBinding := nat.PortBinding{HostIP: "0.0.0.0", HostPort: hostPort2} + + options.Cmd = []string{"sleep", "Infinity"} + options.HostConfig.PortBindings = nat.PortMap{ + tcpPort: []nat.PortBinding{tcpPortBinding}, + udpPort: []nat.PortBinding{udpPortBinding}, + } + + // create and start container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + command.Run(opt, "start", testContainerName) + + // inspect container + resp := command.Stdout(opt, "inspect", testContainerName) + var inspect []*dockercompat.Container + err := json.Unmarshal(resp, &inspect) + Expect(err).Should(BeNil()) + Expect(inspect).Should(HaveLen(1)) + + // verify port mappings + Expect(inspect[0].NetworkSettings).ShouldNot(BeNil()) + portMap := *inspect[0].NetworkSettings.Ports + Expect(portMap).Should(HaveLen(2)) + Expect(portMap[tcpPort]).Should(HaveLen(1)) + Expect(portMap[tcpPort][0]).Should(Equal(tcpPortBinding)) + Expect(portMap[udpPort]).Should(HaveLen(1)) + Expect(portMap[udpPort][0]).Should(Equal(udpPortBinding)) + }) + + // Volume Mounts + + It("should create a container with a directory mounted from the host", func() { + fileContent := "hello world" + hostFilepath := ffs.CreateTempFile("test-file", fileContent) + ginkgo.DeferCleanup(os.RemoveAll, filepath.Dir(hostFilepath)) + ctrFilepath := "/tmp/test-mount/test-file" + + // define options + options.HostConfig.Binds = []string{ + fmt.Sprintf("%s:%s", filepath.Dir(hostFilepath), filepath.Dir(ctrFilepath)), + } + options.Cmd = []string{"sleep", "Infinity"} + + // create and start container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + command.Run(opt, "start", testContainerName) + + // ensure that mounted file exists in container + fileShouldExistInContainer(opt, testContainerName, ctrFilepath, fileContent) + + // ensure that write permissions are enabled on the mounted directory + fileContent2 := "hello world again" + filename2 := "test-file2" + cmd := fmt.Sprintf("echo -n %s > %s", fileContent2, filepath.Join(filepath.Dir(ctrFilepath), filename2)) + command.Run(opt, "exec", testContainerName, "sh", "-c", cmd) + fileShouldExist(filepath.Join(filepath.Dir(hostFilepath), filename2), fileContent2) + }) + It("should create a container with a directory mounted from the host with read-only permissions", func() { + fileContent := "hello world" + hostFilepath := ffs.CreateTempFile("test-file", fileContent) + ginkgo.DeferCleanup(os.RemoveAll, filepath.Dir(hostFilepath)) + ctrFilepath := "/tmp/test-mount/test-file" + + // define options + options.HostConfig.Binds = []string{ + fmt.Sprintf("%s:%s:ro", filepath.Dir(hostFilepath), filepath.Dir(ctrFilepath)), + } + options.Cmd = []string{"sleep", "Infinity"} + + // create and start container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + command.Run(opt, "start", testContainerName) + + // ensure that mounted file exists in container + fileShouldExistInContainer(opt, testContainerName, ctrFilepath, fileContent) + + // ensure that write permissions are disabled on the mounted directory + fileContent2 := "hello world again" + filename2 := "test-file2" + cmd := fmt.Sprintf("echo -n %s > %s", fileContent2, filepath.Join(filepath.Dir(ctrFilepath), filename2)) + command.RunWithoutSuccessfulExit(opt, "exec", testContainerName, "sh", "-c", cmd) + fileShouldNotExist(filepath.Join(filepath.Dir(hostFilepath), filename2)) + }) + It("should create a container with a volume mount", func() { + fileContent := "hello world" + ctrFilepath := "/mnt/test-volume/test-file" + + // create volume + command.Run(opt, "volume", "create", testVolumeName) + + // define options + options.HostConfig.Binds = []string{ + fmt.Sprintf("%s:%s", testVolumeName, filepath.Dir(ctrFilepath)), + } + options.Cmd = []string{"sleep", "Infinity"} + + // create and start container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + command.Run(opt, "start", testContainerName) + + // write file in the mounted volume + cmd := fmt.Sprintf("echo -n %s > %s", fileContent, ctrFilepath) + command.Run(opt, "exec", testContainerName, "sh", "-c", cmd) + + // ensure that created file exists in another container with the same volume mount + statusCode, ctr = createContainer(uClient, url, testContainerName2, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + command.Run(opt, "start", testContainerName2) + fileShouldExistInContainer(opt, testContainerName2, ctrFilepath, fileContent) + }) + + // User and Environment Config + + It("should create a container with specified entrypoint", func() { + // define options + options.Entrypoint = []string{"/bin/echo"} + options.Cmd = []string{"hello", "world"} + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify entrypoint output + out := command.StdoutStr(opt, "start", "-a", testContainerName) + Expect(out).Should(Equal("hello world")) + }) + It("should create a container with environment variables set", func() { + envName := "TESTVAR" + envValue := "test-var-value" + + // define options + options.Env = []string{fmt.Sprintf("%s=%s", envName, envValue)} + cmd := fmt.Sprintf("echo $%s", envName) + options.Cmd = []string{"sh", "-c", cmd} + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify output + out := command.StdoutStr(opt, "start", "-a", testContainerName) + Expect(out).Should(Equal(envValue)) + }) + It("should create a container with defined labels", func() { + labelName := "test-label" + labelValue := "test-label-value" + + // define options + options.Labels = map[string]string{labelName: labelValue} + options.Cmd = []string{"sleep", "Infinity"} + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container + command.Run(opt, "start", testContainerName) + + // inspect container + resp := command.Stdout(opt, "inspect", testContainerName) + var inspect []*dockercompat.Container + err := json.Unmarshal(resp, &inspect) + Expect(err).Should(BeNil()) + Expect(inspect).Should(HaveLen(1)) + + // check label + Expect(inspect[0].Config.Labels[labelName]).Should(Equal(labelValue)) + }) + It("should create a container with specified user", func() { + userName := "nobody" + + // define options + options.User = userName + options.Cmd = []string{"whoami"} + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify user + out := command.StdoutStr(opt, "start", "-a", testContainerName) + Expect(out).Should(Equal(userName)) + }) + It("should create a container with specified work directory", func() { + workdir := "/etc/opt" + + // define options + options.WorkingDir = workdir + options.Cmd = []string{"pwd"} + + // create container + statusCode, ctr := createContainer(uClient, url, testContainerName, options) + Expect(statusCode).Should(Equal(http.StatusCreated)) + Expect(ctr.ID).ShouldNot(BeEmpty()) + + // start container and verify current directory + out := command.StdoutStr(opt, "start", "-a", testContainerName) + Expect(out).Should(Equal(workdir)) + }) + }) +} + +// creates a container with given options and returns http status code and response +func createContainer(client *http.Client, url, ctrName string, ctrOptions types.ContainerCreateRequest) (int, containerCreateResponse) { + // send create request + reqBody, err := json.Marshal(ctrOptions) + Expect(err).Should(BeNil()) + url = url + fmt.Sprintf("?name=%s", ctrName) + res, err := client.Post(url, "application/json", bytes.NewReader(reqBody)) + Expect(err).Should(BeNil()) + + // parse response and status code + var ctr containerCreateResponse + err = json.NewDecoder(res.Body).Decode(&ctr) + Expect(err).Should(BeNil()) + return res.StatusCode, ctr +} + +// verifies that the container is connected to the network specified +func verifyNetworkSettings(opt *option.Option, ctrName, network string) { + // inspect network + resp := command.Stdout(opt, "network", "inspect", network) + var inspectNetResp []*dockercompat.Network + err := json.Unmarshal(resp, &inspectNetResp) + Expect(err).Should(BeNil()) + Expect(inspectNetResp).Should(HaveLen(1)) + inspectNet := inspectNetResp[0] + Expect(inspectNet.IPAM.Config).Should(HaveLen(1)) + gateway := strings.Split(inspectNet.IPAM.Config[0].Gateway, ".") + Expect(gateway).Should(HaveLen(4)) + expectedMask := strings.Join(gateway[:3], ".") + + // inspect container + resp = command.Stdout(opt, "inspect", ctrName) + var inspectCtrResp []*dockercompat.Container + err = json.Unmarshal(resp, &inspectCtrResp) + Expect(err).Should(BeNil()) + Expect(inspectCtrResp).Should(HaveLen(1)) + inspectCtr := inspectCtrResp[0] + + // ensure that container is connected to the specified network + foundNetwork := "" + Expect(inspectCtr.NetworkSettings).ShouldNot(BeNil()) + Expect(inspectCtr.NetworkSettings.IPAddress).ShouldNot(BeEmpty()) + Expect(inspectCtr.NetworkSettings.Networks).ShouldNot(BeEmpty()) + for netName, netSettings := range inspectCtr.NetworkSettings.Networks { + Expect(netSettings).ShouldNot(BeNil()) + if strings.HasPrefix(netSettings.IPAddress, expectedMask) { + foundNetwork = netName + } + } + Expect(foundNetwork).ShouldNot(BeEmpty()) +} diff --git a/e2e/tests/container_list.go b/e2e/tests/container_list.go new file mode 100644 index 00000000..c3cd8dcd --- /dev/null +++ b/e2e/tests/container_list.go @@ -0,0 +1,254 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// ContainerList tests the `GET containers/json` API. +func ContainerList(opt *option.Option) { + Describe("list containers", func() { + var ( + uClient *http.Client + version string + wantContainerName, wantContainerName2 string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + wantContainerName = fmt.Sprintf("/%s", testContainerName) + wantContainerName2 = fmt.Sprintf("/%s", testContainerName2) + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should list the running containers with no query parameter", func() { + id := command.StdoutStr(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + want := []types.ContainerListItem{ + { + Id: id[:12], + Names: []string{wantContainerName}, + }, + } + + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + var got []types.ContainerListItem + err = json.NewDecoder(res.Body).Decode(&got) + Expect(err).Should(BeNil()) + Expect(len(got)).Should(Equal(2)) + got = filterContainerList(got) + Expect(got).Should(ContainElements(want)) + }) + It("should list all the containers with all is true", func() { + id1 := command.StdoutStr(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + id2 := command.StdoutStr(opt, "run", "-d", "--name", testContainerName2, defaultImage) + want := []types.ContainerListItem{ + { + Id: id1[:12], + Names: []string{wantContainerName}, + }, + { + Id: id2[:12], + Names: []string{wantContainerName2}, + }, + } + + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?all=true")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + var got []types.ContainerListItem + err = json.NewDecoder(res.Body).Decode(&got) + Expect(err).Should(BeNil()) + Expect(len(got)).Should(Equal(3)) + got = filterContainerList(got) + Expect(got).Should(ContainElements(want)) + }) + It("should list all the containers with all is true and limit is 1", func() { + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + id2 := command.StdoutStr(opt, "run", "-d", "--name", testContainerName2, defaultImage) + want := []types.ContainerListItem{ + { + Id: id2[:12], + Names: []string{wantContainerName2}, + }, + } + + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?all=true&limit=1")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + var got []types.ContainerListItem + err = json.NewDecoder(res.Body).Decode(&got) + Expect(err).Should(BeNil()) + Expect(len(got)).Should(Equal(1)) + got = filterContainerList(got) + Expect(got).Should(ContainElements(want)) + }) + + It("should list empty list with all is true and filters including exited containers but there is no exited container", func() { + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?all=true&filters={\"status\":[\"exited\"]}")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + var got []types.ContainerListItem + err = json.NewDecoder(res.Body).Decode(&got) + Expect(err).Should(BeNil()) + Expect(len(got)).Should(Equal(0)) + }) + It("should list the running containers as normal with size is true", func() { + id := command.StdoutStr(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + want := []types.ContainerListItem{ + { + Id: id[:12], + Names: []string{wantContainerName}, + }, + } + + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?size=true")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + var got []types.ContainerListItem + err = json.NewDecoder(res.Body).Decode(&got) + Expect(err).Should(BeNil()) + Expect(len(got)).Should(Equal(2)) + got = filterContainerList(got) + Expect(got).Should(ContainElements(want)) + }) + It("should list the running containers with all is true and filters including exited status", func() { + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + id2 := command.StdoutStr(opt, "run", "-d", "--name", testContainerName2, defaultImage) + want := []types.ContainerListItem{ + { + Id: id2[:12], + Names: []string{wantContainerName2}, + }, + } + + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?all=true&filters={\"status\":[\"exited\"]}")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + var got []types.ContainerListItem + err = json.NewDecoder(res.Body).Decode(&got) + Expect(err).Should(BeNil()) + Expect(len(got)).Should(Equal(1)) + got = filterContainerList(got) + Expect(got).Should(ContainElements(want)) + }) + It("should list the running containers with filters including labels", func() { + id := command.StdoutStr(opt, "run", "-d", "--name", testContainerName, "--label", "com.example.foo=bar", defaultImage, "sleep", "infinity") + want := []types.ContainerListItem{ + { + Id: id[:12], + Names: []string{wantContainerName}, + }, + } + + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?all=true&filters={\"label\":[\"com.example.foo=bar\"]}")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + var got []types.ContainerListItem + err = json.NewDecoder(res.Body).Decode(&got) + Expect(err).Should(BeNil()) + Expect(len(got)).Should(Equal(1)) + got = filterContainerList(got) + Expect(got).Should(ContainElements(want)) + }) + It("should list the running containers with filters including network", func() { + command.Run(opt, "network", "create", testNetwork) + id := command.StdoutStr(opt, "run", "-d", "--name", testContainerName, "--network", testNetwork, defaultImage, "sleep", "infinity") + want := []types.ContainerListItem{ + { + Id: id[:12], + Names: []string{wantContainerName}, + }, + } + + res, err := uClient.Get(client.ConvertToFinchUrl(version, fmt.Sprintf("/containers/json?all=true&filters={\"network\":[\"%s\"]}", testNetwork))) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + var got []types.ContainerListItem + err = json.NewDecoder(res.Body).Decode(&got) + Expect(err).Should(BeNil()) + Expect(len(got)).Should(Equal(1)) + got = filterContainerList(got) + Expect(got).Should(ContainElements(want)) + }) + It("should return 400 error when all parameter is invalid", func() { + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?all=invalid")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusBadRequest)) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + defer res.Body.Close() + errorMsg := fmt.Sprintf("invalid query parameter \\\"all\\\": %s", fmt.Errorf("strconv.ParseBool: parsing \\\"invalid\\\": invalid syntax")) + Expect(body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + }) + It("should return 400 error when limit parameter is invalid", func() { + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?limit=invalid")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusBadRequest)) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + defer res.Body.Close() + errorMsg := fmt.Sprintf("invalid query parameter \\\"limit\\\": %s", fmt.Errorf("strconv.ParseInt: parsing \\\"invalid\\\": invalid syntax")) + Expect(body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + }) + It("should return 400 error when size parameter is invalid", func() { + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?size=invalid")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusBadRequest)) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + defer res.Body.Close() + errorMsg := fmt.Sprintf("invalid query parameter \\\"size\\\": %s", fmt.Errorf("strconv.ParseBool: parsing \\\"invalid\\\": invalid syntax")) + Expect(body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + }) + It("should return 400 error when filters parameter is invalid", func() { + res, err := uClient.Get(client.ConvertToFinchUrl(version, "/containers/json?filters=invalid")) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusBadRequest)) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + defer res.Body.Close() + errorMsg := fmt.Sprintf("invalid query parameter \\\"filters\\\": %s", fmt.Errorf("invalid character 'i' looking for beginning of value")) + Expect(body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + }) + }) +} + +// Checks that the other field for containers is non-null, then sets it to a zero value for comparison with dummy values +func filterContainerList(got []types.ContainerListItem) []types.ContainerListItem { + filtered := []types.ContainerListItem{} + for _, cont := range got { + Expect(cont.CreatedAt).ShouldNot(BeZero()) + Expect(cont.Image).ShouldNot(BeEmpty()) + Expect(cont.Labels).ShouldNot(BeNil()) + Expect(cont.State).ShouldNot(BeEmpty()) + if cont.State != "exited" { + Expect(cont.NetworkSettings.DefaultNetworkSettings.IPAddress).ShouldNot(BeEmpty()) + } + cont.CreatedAt = 0 + cont.Image = "" + cont.Labels = nil + cont.NetworkSettings = nil + cont.Mounts = nil + cont.State = "" + filtered = append(filtered, cont) + } + return filtered +} diff --git a/e2e/tests/container_logs.go b/e2e/tests/container_logs.go new file mode 100644 index 00000000..e9ae52d5 --- /dev/null +++ b/e2e/tests/container_logs.go @@ -0,0 +1,146 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "fmt" + "io" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + + "github.com/runfinch/finch-daemon/e2e/client" +) + +// ContainerLogs tests the `POST containers/logs` API. +func ContainerLogs(opt *option.Option) { + Describe("get logs from a container", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + // run container in detached mode, outputting 1, 2, 3 in different lines + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, + "/bin/sh", "-c", `for VAR in 1 2 3; do echo $VAR; done; sleep infinity`) + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + It("should return a 404 status if the container is not found", func() { + // create url and options + notFoundName := "doesnt-exist" + relativeUrl := fmt.Sprintf("/containers/%s/logs", notFoundName) + opts := "?stdout=1" + + "&stderr=1" + + // call the endpoint + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl+opts)) + Expect(err).Should(BeNil()) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + Expect(string(body)).Should(ContainSubstring(`no such container: ` + notFoundName)) + }) + It("should return a bad request and do nothing when stdout and stderr are false", func() { + // create url and options + relativeUrl := fmt.Sprintf("/containers/%s/logs", testContainerName) + opts := "?stdout=0" + + "&stderr=0" + + // call the endpoint + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl+opts)) + Expect(err).Should(BeNil()) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusBadRequest)) + Expect(string(body)).Should(MatchJSON(`{"message":"you must choose at least one stream"}`)) + }) + It("should succeed logging a running container without following", func() { + // create url and options + relativeUrl := fmt.Sprintf("/containers/%s/logs", testContainerName) + opts := "?stdout=1" + + "&stderr=1" + + "&follow=0" + + "&tail=0" + + // wait for container to run & echo the output, then call endpoint + time.Sleep(1 * time.Second) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl+opts)) + Expect(err).Should(BeNil()) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + // response body is made up of the stream format explained here: + // https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerAttach + // basically, header := [8]byte{STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4} + Expect(body[8]).Should(Equal(byte('1'))) + Expect(body[18]).Should(Equal(byte('2'))) + Expect(body[28]).Should(Equal(byte('3'))) + }) + It("should succeed in logging the last 1 line given a tail", func() { + // create url and options + relativeUrl := fmt.Sprintf("/containers/%s/logs", testContainerName) + opts := "?stdout=1" + + "&stderr=1" + + "&follow=0" + + "&tail=1" + + // wait for container to run & echo the output, then call endpoint + time.Sleep(1 * time.Second) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl+opts)) + Expect(err).Should(BeNil()) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + // response body is made up of the stream format explained here: + // https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerAttach + // basically, header := [8]byte{STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4} + Expect(body[8]).Should(Equal(byte('3'))) + }) + It("should succeed in logging with follow enabled", func() { + altCtrName := "ctr-test2" + command.Run(opt, "run", "-d", "--name", altCtrName, defaultImage, + "/bin/sh", "-c", `for VAR in 1 2 3; do echo $VAR; done; sleep 2; for VAR in a b c; do echo $VAR; done`) + // create url and options + relativeUrl := fmt.Sprintf("/containers/%s/logs", altCtrName) + opts := "?stdout=1" + + "&stderr=1" + + "&tail=1" + + "&follow=1" + + // wait for container to reach steady state, then call endpoint + time.Sleep(1 * time.Second) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl+opts)) + Expect(err).Should(BeNil()) + + time.Sleep(4 * time.Second) + body, err := io.ReadAll(res.Body) + _ = res.Body.Close() + + // make assertions + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + Expect(body[8]).Should(Equal(byte('3'))) + Expect(body[18]).Should(Equal(byte('a'))) + Expect(body[28]).Should(Equal(byte('b'))) + Expect(body[38]).Should(Equal(byte('c'))) + }) + }) +} diff --git a/e2e/tests/container_remove.go b/e2e/tests/container_remove.go new file mode 100644 index 00000000..49a5612d --- /dev/null +++ b/e2e/tests/container_remove.go @@ -0,0 +1,95 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +// ContainerRemove tests the `POST containers/{id}/remove` API. +func ContainerRemove(opt *option.Option) { + Describe("remove a container", func() { + var ( + uClient *http.Client + version string + apiUrl string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + relativeUrl := fmt.Sprintf("/containers/%s/remove", testContainerName) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should remove the container", func() { + // start a container that exits as soon as it starts + command.Run(opt, "run", "--name", testContainerName, defaultImage, "echo", "foo") + command.Run(opt, "wait", testContainerName) + + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + containerShouldNotExist(opt, testContainerName) + }) + It("should fail to remove a running container", func() { + // start a container that keeps running + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusConflict)) + containerShouldExist(opt, testContainerName) + }) + It("should successfully remove a running container with force=true", func() { + // start a container that keeps running + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + + relativeUrl := fmt.Sprintf("/containers/%s/remove?force=true", testContainerName) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + containerShouldNotExist(opt, testContainerName) + }) + It("should successfully remove a volume associated with it", func() { + // start a container that keeps running + command.Run(opt, "run", "-v", "test-vol", "--name", testContainerName, defaultImage) + command.Run(opt, "wait", testContainerName) + // get the total number of volumes after creating the new volume + totalVolumes := len(command.GetAllVolumeNames(opt)) + relativeUrl := fmt.Sprintf("/containers/%s/remove?v=true", testContainerName) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + containerShouldNotExist(opt, testContainerName) + vCountAfterRemove := len(command.GetAllVolumeNames(opt)) + Expect(vCountAfterRemove).Should(Equal(totalVolumes - 1)) + }) + It("should fail to remove a container that does not exist", func() { + // don't create the container and call remove api. + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + var errResponse response.Error + err = json.NewDecoder(res.Body).Decode(&errResponse) + Expect(err).Should(BeNil()) + Expect(errResponse.Message).Should(Not(BeEmpty())) + }) + + }) +} diff --git a/e2e/tests/container_rename.go b/e2e/tests/container_rename.go new file mode 100644 index 00000000..11278d30 --- /dev/null +++ b/e2e/tests/container_rename.go @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +// ContainerRename tests the `POST containers/{id}/rename` API. +func ContainerRename(opt *option.Option) { + Describe("rename a container", func() { + var ( + uClient *http.Client + version string + apiUrl string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should rename the container", func() { + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + containerShouldBeRunning(opt, testContainerName) + containerShouldNotExist(opt, testContainerName2) + + relativeUrl := fmt.Sprintf("/containers/%s/rename?name=%s", testContainerName, testContainerName2) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + containerShouldBeRunning(opt, testContainerName2) + }) + It("should fail to rename a container to taken name", func() { + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + command.Run(opt, "run", "-d", "--name", testContainerName2, defaultImage, "sleep", "infinity") + containerShouldBeRunning(opt, testContainerName) + containerShouldBeRunning(opt, testContainerName2) + + relativeUrl := fmt.Sprintf("/containers/%s/rename?name=%s", testContainerName, testContainerName2) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusConflict)) + }) + It("should fail to rename a container that does not exist", func() { + containerShouldNotExist(opt, testContainerName) + + relativeUrl := fmt.Sprintf("/containers/%s/rename?name=%s", testContainerName, testContainerName2) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + var errResponse response.Error + err = json.NewDecoder(res.Body).Decode(&errResponse) + Expect(err).Should(BeNil()) + Expect(errResponse.Message).Should(Not(BeEmpty())) + }) + }) +} diff --git a/e2e/tests/container_start.go b/e2e/tests/container_start.go new file mode 100644 index 00000000..16b44666 --- /dev/null +++ b/e2e/tests/container_start.go @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +// ContainerStart tests the `POST containers/{id}/start` API. +func ContainerStart(opt *option.Option) { + Describe("start a container", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + command.Run(opt, "create", "--name", testContainerName, defaultImage, "echo", "foo") + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should start the container", func() { + relativeUrl := fmt.Sprintf("/containers/%s/start", testContainerName) + res, err := uClient.Post(client.ConvertToFinchUrl(version, relativeUrl), "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + }) + It("should fail to start the container", func() { + // start a container that does not exist + relativeUrl := client.ConvertToFinchUrl(version, "/containers/container-does-not-exist/start") + res, err := uClient.Post(relativeUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + var errResponse response.Error + err = json.NewDecoder(res.Body).Decode(&errResponse) + Expect(err).Should(BeNil()) + Expect(errResponse.Message).Should(Not(BeEmpty())) + }) + + }) +} diff --git a/e2e/tests/container_stats.go b/e2e/tests/container_stats.go new file mode 100644 index 00000000..fb7b220b --- /dev/null +++ b/e2e/tests/container_stats.go @@ -0,0 +1,290 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "time" + + dockertypes "github.com/docker/docker/api/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// ContainerStats tests the `GET containers/{id}/stats` API. +func ContainerStats(opt *option.Option) { + Describe("get container stats", func() { + var ( + uClient *http.Client + version string + wantContainerName string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + wantContainerName = fmt.Sprintf("/%s", testContainerName) + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should return a 404 error if container does not exist", func() { + url := client.ConvertToFinchUrl(version, "/containers/container-does-not-exist/stats") + res, err := uClient.Get(url) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + var errResponse response.Error + err = json.NewDecoder(res.Body).Decode(&errResponse) + Expect(err).Should(BeNil()) + Expect(errResponse.Message).ShouldNot(BeEmpty()) + }) + It("should return container stats from container name without streaming", func() { + cid := command.StdoutStr( + opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "Infinity", + ) + + // get container stats + relativeUrl := fmt.Sprintf("/containers/%s/stats?stream=false", testContainerName) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl)) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // check json contents + var statsJSON types.StatsJSON + err = json.NewDecoder(res.Body).Decode(&statsJSON) + Expect(err).Should(BeNil()) + expectValidStats(&statsJSON, wantContainerName, cid, 1) + }) + It("should return container stats from long container ID without streaming", func() { + cid := command.StdoutStr( + opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "Infinity", + ) + + // get container stats + relativeUrl := fmt.Sprintf("/containers/%s/stats?stream=false", cid) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl)) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // check json contents + var statsJSON types.StatsJSON + err = json.NewDecoder(res.Body).Decode(&statsJSON) + Expect(err).Should(BeNil()) + expectValidStats(&statsJSON, wantContainerName, cid, 1) + }) + It("should return container stats from short container ID without streaming", func() { + cid := command.StdoutStr( + opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "Infinity", + ) + + // get container stats + relativeUrl := fmt.Sprintf("/containers/%s/stats?stream=false", cid[:12]) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl)) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // check json contents + var statsJSON types.StatsJSON + err = json.NewDecoder(res.Body).Decode(&statsJSON) + Expect(err).Should(BeNil()) + expectValidStats(&statsJSON, wantContainerName, cid, 1) + }) + It("should stream container stats until the container is removed", func() { + cid := command.StdoutStr( + opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "Infinity", + ) + + // start a routine to remove the container in 3 seconds + go func() { + time.Sleep(time.Second * 3) + command.Run(opt, "rm", "-f", testContainerName) + }() + + // get container stats + relativeUrl := fmt.Sprintf("/containers/%s/stats", testContainerName) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl)) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // check json contents + scanner := bufio.NewScanner(res.Body) + num := 0 + for scanner.Scan() { + var statsJSON types.StatsJSON + err = json.Unmarshal(scanner.Bytes(), &statsJSON) + Expect(err).Should(BeNil()) + expectValidStats(&statsJSON, wantContainerName, cid, 1) + num += 1 + } + // should tick at least twice in 3 seconds + Expect(num).Should(BeNumerically(">", 1)) + Expect(num).Should(BeNumerically("<", 5)) + }) + It("should stream stats for a stopped container", func() { + cid := command.StdoutStr( + opt, "run", "-d", "--name", testContainerName, defaultImage, "echo", "hello", + ) + command.Run(opt, "wait", testContainerName) + + // start a routine to remove the container in 3 seconds + go func() { + time.Sleep(time.Second * 3) + command.Run(opt, "rm", "-f", testContainerName) + }() + + // get container stats + relativeUrl := fmt.Sprintf("/containers/%s/stats", testContainerName) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl)) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // check json contents + scanner := bufio.NewScanner(res.Body) + num := 0 + for scanner.Scan() { + var statsJSON types.StatsJSON + err = json.Unmarshal(scanner.Bytes(), &statsJSON) + Expect(err).Should(BeNil()) + expectEmptyStats(&statsJSON, wantContainerName, cid) + num += 1 + } + // should tick at least twice in 3 seconds + Expect(num).Should(BeNumerically(">", 1)) + Expect(num).Should(BeNumerically("<", 5)) + }) + It("should stream stats when no network interface is created", func() { + cid := command.StdoutStr( + opt, + "run", + "-d", + "--net", "none", + "--name", testContainerName, + defaultImage, + "sleep", "Infinity", + ) + + // start a routine to remove the container in 3 seconds + go func() { + time.Sleep(time.Second * 3) + command.Run(opt, "rm", "-f", testContainerName) + }() + + // get container stats + relativeUrl := fmt.Sprintf("/containers/%s/stats", testContainerName) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl)) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // check json contents + scanner := bufio.NewScanner(res.Body) + num := 0 + for scanner.Scan() { + var statsJSON types.StatsJSON + err = json.Unmarshal(scanner.Bytes(), &statsJSON) + Expect(err).Should(BeNil()) + expectValidStats(&statsJSON, wantContainerName, cid, 0) + num += 1 + } + // should tick at least twice in 3 seconds + Expect(num).Should(BeNumerically(">", 1)) + Expect(num).Should(BeNumerically("<", 5)) + }) + It("should stream stats with multiple network interfaces", func() { + // create networks and run a container with multiple networks + command.Run(opt, "network", "create", "net1") + command.Run(opt, "network", "create", "net2") + cid := command.StdoutStr( + opt, + "run", + "-d", + "--net", "net1", + "--net", "net2", + "--name", testContainerName, + defaultImage, + "sleep", "Infinity", + ) + + // start a routine to remove the container in 3 seconds + go func() { + time.Sleep(time.Second * 3) + command.Run(opt, "rm", "-f", testContainerName) + }() + + // get container stats + relativeUrl := fmt.Sprintf("/containers/%s/stats", testContainerName) + res, err := uClient.Get(client.ConvertToFinchUrl(version, relativeUrl)) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // check json contents + scanner := bufio.NewScanner(res.Body) + num := 0 + for scanner.Scan() { + var statsJSON types.StatsJSON + err = json.Unmarshal(scanner.Bytes(), &statsJSON) + Expect(err).Should(BeNil()) + expectValidStats(&statsJSON, wantContainerName, cid, 2) + num += 1 + } + // should tick at least twice in 3 seconds + Expect(num).Should(BeNumerically(">", 1)) + Expect(num).Should(BeNumerically("<", 5)) + }) + }) +} + +// expectValidStats ensures that the data contained in the stats object is valid +func expectValidStats(st *types.StatsJSON, name, id string, numNetworks int) { + // verify container name and ID + Expect(st.Name).Should(Equal(name)) + Expect(st.ID).Should(Equal(id)) + + // check that the time difference between last read and current read + // is approximately 1 second + t := time.Time{} + if st.PreRead != t { + Expect(st.Read).Should(BeTemporally("~", st.PreRead.Add(time.Second), time.Millisecond*100)) + } + + // check that number of current PIDs is > 0 + Expect(st.PidsStats.Current).ShouldNot(BeZero()) + + // check that number of CPUs and system usage is > 0 + Expect(st.CPUStats.OnlineCPUs).ShouldNot(BeZero()) + Expect(st.CPUStats.SystemUsage).ShouldNot(BeZero()) + + // check that object contains networks data + if numNetworks == 0 { + Expect(st.Networks).Should(BeNil()) + } else { + Expect(st.Networks).ShouldNot(BeNil()) + Expect(len(st.Networks)).Should(Equal(numNetworks)) + } +} + +// expectEmptyStats ensures that the data contained in the stats object is empty +// which is the case with containers that are not running +func expectEmptyStats(st *types.StatsJSON, name, id string) { + // verify container name and ID + Expect(st.Name).Should(Equal(name)) + Expect(st.ID).Should(Equal(id)) + + Expect(st.Read).Should(Equal(time.Time{})) + Expect(st.PreRead).Should(Equal(time.Time{})) + Expect(st.PidsStats).Should(Equal(dockertypes.PidsStats{})) + Expect(st.BlkioStats).Should(Equal(dockertypes.BlkioStats{})) + Expect(st.CPUStats).Should(Equal(types.CPUStats{})) + Expect(st.PreCPUStats).Should(Equal(types.CPUStats{})) + Expect(st.MemoryStats).Should(Equal(dockertypes.MemoryStats{})) +} diff --git a/e2e/tests/container_stop.go b/e2e/tests/container_stop.go new file mode 100644 index 00000000..0cb60977 --- /dev/null +++ b/e2e/tests/container_stop.go @@ -0,0 +1,86 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +// ContainerStop tests the `POST containers/{id}/stop` API. +func ContainerStop(opt *option.Option) { + Describe("stop a container", func() { + var ( + uClient *http.Client + version string + apiUrl string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + relativeUrl := fmt.Sprintf("/containers/%s/stop", testContainerName) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should stop the container", func() { + // start a container that keeps running + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + containerShouldBeRunning(opt, testContainerName) + + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + containerShouldNotBeRunning(opt, testContainerName) + }) + It("should fail to stop a stopped container", func() { + // start a container that exits as soon as starts + command.Run(opt, "run", "--name", testContainerName, defaultImage, "echo", "foo") + command.Run(opt, "wait", testContainerName) + + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotModified)) + }) + It("should fail to stop a container that does not exist", func() { + // don't create the container and call stop api. + res, err := uClient.Post(apiUrl, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + var errResponse response.Error + err = json.NewDecoder(res.Body).Decode(&errResponse) + Expect(err).Should(BeNil()) + Expect(errResponse.Message).Should(Not(BeEmpty())) + }) + It("should stop the container", func() { + // start a container that keeps running + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + containerShouldBeRunning(opt, testContainerName) + + // stop the container with a timeout of 10 seconds + now := time.Now() + relativeUrl := fmt.Sprintf("/containers/%s/stop?t=10", testContainerName) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + res, err := uClient.Post(apiUrl, "application/json", nil) + later := time.Now() + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + elapsed := later.Sub(now) + Expect(elapsed.Seconds()).Should(BeNumerically(">", 9.0)) + }) + }) +} diff --git a/e2e/tests/events.go b/e2e/tests/events.go new file mode 100644 index 00000000..ca347a91 --- /dev/null +++ b/e2e/tests/events.go @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + eventtype "github.com/runfinch/finch-daemon/pkg/api/events" +) + +// SystemEvents tests streaming container events +func SystemEvents(opt *option.Option) { + Describe("stream container events", func() { + var ( + uClient *http.Client + version string + tagUrl string + imageId string + ) + BeforeEach(func() { + imageId = pullImage(opt, defaultImage) + uClient = client.NewClient(GetDockerHostUrl()) + version = GetDockerApiVersion() + tagUrl = client.ConvertToFinchUrl(version, fmt.Sprintf("/images/%s/tag?repo=test&tag=test", defaultImage)) + }) + AfterEach(func() { + removeImage(opt, defaultImage) + }) + It("should successfully stream image tag events", func() { + relativeUrl := "/events" + finchUrl := client.ConvertToFinchUrl(version, relativeUrl) + + res, err := uClient.Get(finchUrl) + Expect(err).Should(BeNil()) + + _, err = uClient.Post(tagUrl, "application/json", nil) + Expect(err).Should(BeNil()) + defer removeImage(opt, "test:test") + + scanner := bufio.NewScanner(res.Body) + scanner.Scan() + read := scanner.Text() + Expect(read).ShouldNot(BeEmpty()) + + event := &eventtype.Event{} + err = json.Unmarshal([]byte(read), event) + Expect(err).Should(BeNil()) + + Expect(event.Type).Should(Equal("image")) + Expect(event.Action).Should(Equal("tag")) + Expect(event.ID).Should(ContainSubstring(imageId)) + Expect(event.Actor.Attributes["name"]).Should(Equal("test:test")) + }) + It("should receive image events when filtering for image events", func() { + // TODO: once we've added more event types, ensure that different event types are not received + relativeUrl := `/events?filters={"type":["image"]}` + finchUrl := client.ConvertToFinchUrl(version, relativeUrl) + + res, err := uClient.Get(finchUrl) + Expect(err).Should(BeNil()) + + _, err = uClient.Post(tagUrl, "application/json", nil) + Expect(err).Should(BeNil()) + defer removeImage(opt, "test:test") + + scanner := bufio.NewScanner(res.Body) + scanner.Scan() + read := scanner.Text() + Expect(read).ShouldNot(BeEmpty()) + + event := &eventtype.Event{} + err = json.Unmarshal([]byte(read), event) + Expect(err).Should(BeNil()) + + Expect(event.Type).Should(Equal("image")) + Expect(event.Action).Should(Equal("tag")) + Expect(event.ID).Should(ContainSubstring(imageId)) + Expect(event.Actor.Attributes["name"]).Should(Equal("test:test")) + }) + }) +} diff --git a/e2e/tests/image_pull.go b/e2e/tests/image_pull.go new file mode 100644 index 00000000..6330b179 --- /dev/null +++ b/e2e/tests/image_pull.go @@ -0,0 +1,135 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "fmt" + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" +) + +// ImagePull tests the `POST images/create` API. +func ImagePull(opt *option.Option) { + Describe("pull an image", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + command.RemoveImages(opt) + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should pull the default image successfully", func() { + relativeUrl := fmt.Sprintf("/images/create?fromImage=%s", defaultImage) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + waitForResponse(resp) + imageShouldExist(opt, defaultImage) + }) + It("should do nothing if image already exists", func() { + command.Run(opt, "pull", defaultImage) + relativeUrl := fmt.Sprintf("/images/create?fromImage=%s", defaultImage) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + waitForResponse(resp) + imageShouldExist(opt, defaultImage) + }) + It("should fail to pull a non-existent image", func() { + relativeUrl := fmt.Sprintf("/images/create?fromImage=%s", nonexistentImageName) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusNotFound)) + waitForResponse(resp) + imageShouldNotExist(opt, nonexistentImageName) + }) + It("should pull the alpine image using the specified image tag", func() { + imageName, imageTag, _ := strings.Cut(olderAlpineImage, ":") + relativeUrl := fmt.Sprintf("/images/create?fromImage=%s&tag=%s", imageName, imageTag) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + waitForResponse(resp) + imageShouldExist(opt, olderAlpineImage) + imageShouldNotExist(opt, alpineImage) + }) + It("should fail to pull an image with a malformed image name", func() { + malformedImage := "alpine:image:latest" + relativeUrl := fmt.Sprintf("/images/create?fromImage=%s", malformedImage) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusBadRequest)) + waitForResponse(resp) + }) + It("should fail to pull an image with a malformed image tag", func() { + imageName, _, _ := strings.Cut(olderAlpineImage, ":") + malformedTag := "image:latest" + relativeUrl := fmt.Sprintf("/images/create?fromImage=%s&tag=%s", imageName, malformedTag) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusBadRequest)) + waitForResponse(resp) + imageShouldNotExist(opt, imageName) + }) + It("should pull the alpine image with the specified platform", func() { + platform := "linux/arm64" + relativeUrl := fmt.Sprintf("/images/create?fromImage=%s&platform=%s", alpineImage, platform) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + waitForResponse(resp) + imageShouldExist(opt, alpineImage) + }) + It("should fail to pull an image with invalid platform", func() { + platform := "invalid" + relativeUrl := fmt.Sprintf("/images/create?fromImage=%s&platform=%s", alpineImage, platform) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusInternalServerError)) + waitForResponse(resp) + imageShouldNotExist(opt, alpineImage) + }) + }) +} + +// waitForResponse waits until the http response is closed with EOF +func waitForResponse(resp *http.Response) { + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + if n == 0 && err != nil { + break + } + } +} diff --git a/e2e/tests/image_push.go b/e2e/tests/image_push.go new file mode 100644 index 00000000..066cab66 --- /dev/null +++ b/e2e/tests/image_push.go @@ -0,0 +1,114 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "fmt" + "io" + "net/http" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/ffs" + "github.com/runfinch/common-tests/fnet" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" +) + +func ImagePush(opt *option.Option) { + Describe("push an image", func() { + var ( + buildContext string + port int + uClient *http.Client + version string + ) + + BeforeEach(func() { + command.RemoveAll(opt) + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + + buildContext = ffs.CreateBuildContext(fmt.Sprintf(`FROM %s + CMD ["echo", "bar"] + `, defaultImage)) + DeferCleanup(os.RemoveAll, buildContext) + port = fnet.GetFreePort() + command.Run(opt, "run", "-dp", fmt.Sprintf("%d:5000", port), "--name", "registry", registryImage) + }) + + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should push an image successfully", func() { + name := fmt.Sprintf(`localhost:%d/test-push:tag`, port) + command.Run(opt, "build", "-t", name, buildContext) + relativeUrl := fmt.Sprintf("/images/%s/push", name) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + command.Run(opt, "rmi", name) + command.Run(opt, "pull", name) + imageShouldExist(opt, name) + }) + It("should successfully push an image into two different registry", func() { + name := fmt.Sprintf(`localhost:%d/test-push:tag`, port) + command.Run(opt, "build", "-t", name, buildContext) + relativeUrl := fmt.Sprintf("/images/%s/push", name) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + command.Run(opt, "rmi", name) + command.Run(opt, "pull", name) + imageShouldExist(opt, name) + + // spin off a second registry and tag the previously built image and push it to the second registry. + secondRegistryPort := fnet.GetFreePort() + command.Run(opt, "run", "-dp", fmt.Sprintf("%d:5000", secondRegistryPort), "--name", "second-registry", registryImage) + + name2 := fmt.Sprintf(`localhost:%d/test-push:tag`, secondRegistryPort) + command.Run(opt, "tag", name, name2) + relativeUrl = fmt.Sprintf("/images/%s/push", name2) + url = client.ConvertToFinchUrl(version, relativeUrl) + resp, err = uClient.Post(url, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + command.Run(opt, "rmi", name2) + command.Run(opt, "pull", name2) + imageShouldExist(opt, name2) + }) + + It("should return an error when pushing a nonexistent tag", func() { + nonexistentTag := fmt.Sprintf(`localhost:%d/nonexistent:tag`, port) + relativeUrl := fmt.Sprintf("/images/%s/push", nonexistentTag) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusNotFound)) + }) + It("should fail due to network error", func() { + // pass the wrong port to mimic network failure + freePort := fnet.GetFreePort() + name := fmt.Sprintf(`localhost:%d/test-push:tag`, freePort) + command.Run(opt, "build", "-t", name, buildContext) + relativeUrl := fmt.Sprintf("/images/%s/push", name) + url := client.ConvertToFinchUrl(version, relativeUrl) + resp, err := uClient.Post(url, "application/json", nil) + Expect(err).Should(BeNil()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + data, _ := io.ReadAll(resp.Body) + Expect(string(data[:])).Should(ContainSubstring(`"errorDetail"`)) + }) + + //TODO: add a e2e test that push an image to ecr public which requires authentication. + }) +} diff --git a/e2e/tests/image_remove.go b/e2e/tests/image_remove.go new file mode 100644 index 00000000..1dbef1d6 --- /dev/null +++ b/e2e/tests/image_remove.go @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "fmt" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" +) + +// ImageRemove tests the delete image API - `DELETE /images/{id}` +func ImageRemove(opt *option.Option) { + Describe("remove an image", func() { + var ( + uClient *http.Client + version string + apiUrl string + req *http.Request + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + relativeUrl := fmt.Sprintf("/images/%s", defaultImage) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + var err error + req, err = http.NewRequest("DELETE", apiUrl, nil) + Expect(err).Should(BeNil()) + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + + Context("by name", func() { + BeforeEach(func() { + relativeUrl := fmt.Sprintf("/images/%s", defaultImage) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + var err error + req, err = http.NewRequest("DELETE", apiUrl, nil) + Expect(err).ShouldNot(HaveOccurred()) + }) + It("should remove the image", func() { + pullImage(opt, defaultImage) + res, err := uClient.Do(req) + Expect(err).ShouldNot(HaveOccurred()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + imageShouldNotExist(opt, defaultImage) + }) + It("should fail to remove the image of a running container", func() { + // start a container that keeps running + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage, "sleep", "infinity") + res, err := uClient.Do(req) + Expect(err).ShouldNot(HaveOccurred()) + Expect(res.StatusCode).Should(Equal(http.StatusConflict)) + imageShouldExist(opt, defaultImage) + }) + It("should fail to remove the image used in a stopped container", func() { + // start a container that exits as soon as starts + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage) + command.Run(opt, "wait", testContainerName) + res, err := uClient.Do(req) + Expect(err).ShouldNot(HaveOccurred()) + Expect(res.StatusCode).Should(Equal(http.StatusConflict)) + imageShouldExist(opt, defaultImage) + }) + It("should successfully remove an image used in a stopped container with force=true", func() { + // start a container that exits as soon as starts + command.Run(opt, "run", "-d", "--name", testContainerName, defaultImage) + command.Run(opt, "wait", testContainerName) + req, err := http.NewRequest("DELETE", apiUrl+"?force=true", nil) + Expect(err).ShouldNot(HaveOccurred()) + res, err := uClient.Do(req) + Expect(err).ShouldNot(HaveOccurred()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + imageShouldNotExist(opt, defaultImage) + }) + It("should fail to remove as image does not exist", func() { + // don't pull the image and try to delete + res, err := uClient.Do(req) + Expect(err).ShouldNot(HaveOccurred()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + }) + }) + Context("by id", func() { + BeforeEach(func() { + imageID := pullImage(opt, defaultImage) + relativeUrl := fmt.Sprintf("/images/%s", imageID) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + var err error + req, err = http.NewRequest("DELETE", apiUrl, nil) + Expect(err).ShouldNot(HaveOccurred()) + }) + It("should successfully remove an image", func() { + res, err := uClient.Do(req) + Expect(err).ShouldNot(HaveOccurred()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + imageShouldNotExist(opt, defaultImage) + }) + It("should fail to remove if multiple image with same id", func() { + //create a new tag will create a reference with same id + command.Run(opt, "image", "tag", defaultImage, "custom-image:latest") + imageShouldExist(opt, "custom-image:latest") + res, err := uClient.Do(req) + Expect(err).ShouldNot(HaveOccurred()) + Expect(res.StatusCode).Should(Equal(http.StatusConflict)) + imageShouldExist(opt, defaultImage) + }) + It("should successfully remove multiple images with same id using force=true", func() { + req, err := http.NewRequest("DELETE", apiUrl+"?force=true", nil) + Expect(err).ShouldNot(HaveOccurred()) + //create a new tag will create a reference with same id + command.Run(opt, "image", "tag", defaultImage, "custom-image:latest") + imageShouldExist(opt, "custom-image:latest") + + res, err := uClient.Do(req) + Expect(err).ShouldNot(HaveOccurred()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + imageShouldNotExist(opt, defaultImage) + imageShouldNotExist(opt, "custom-image:latest") + + }) + //TODO: need to add a e2e test to make sure proper untagged and deleted value is generated for image remove api. + + }) + }) +} diff --git a/e2e/tests/network_create.go b/e2e/tests/network_create.go new file mode 100644 index 00000000..93acc191 --- /dev/null +++ b/e2e/tests/network_create.go @@ -0,0 +1,246 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +func NetworkCreate(opt *option.Option) { + Describe("create a network", func() { + const ( + path = "/networks/create" + contentType = "application/json" + ) + + var ( + uclient *http.Client + version = GetDockerApiVersion() + createNetworkURL = client.ConvertToFinchUrl(version, path) + ) + + BeforeEach(func() { + uclient = client.NewClient(GetDockerHostUrl()) + }) + + var createNetwork = func(request types.NetworkCreateRequest) *http.Response { + json, err := json.Marshal(request) + Expect(err).ShouldNot(HaveOccurred(), "marshalling request to JSON") + + httpResponse, err := uclient.Post(createNetworkURL, contentType, bytes.NewReader(json)) + Expect(err).ShouldNot(HaveOccurred()) + + return httpResponse + } + + var unmarshallHTTPResponse = func(httpResponse *http.Response) *types.NetworkCreateResponse { + response := &types.NetworkCreateResponse{} + + body, err := io.ReadAll(httpResponse.Body) + Expect(err).ShouldNot(HaveOccurred(), "reading response body") + + err = json.Unmarshal(body, response) + Expect(err).ShouldNot(HaveOccurred(), "unmarshalling response from JSON") + + return response + } + + var cleanupNetwork = func(id string) func() { + return func() { + command.Run(opt, "network", "remove", id) + } + } + + When("a create network request is received with required configuration", func() { + It("should return 201 Created and the network ID", func() { + request := types.NewCreateNetworkRequest(testNetwork) + + httpResponse := createNetwork(*request) + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusCreated)) + + response := unmarshallHTTPResponse(httpResponse) + Expect(response.ID).ShouldNot(BeEmpty()) + DeferCleanup(cleanupNetwork(response.ID)) + Expect(response.Warning).Should(BeEmpty()) + }) + }) + + When("a create network request is received with explicit default configuration", func() { + It("should return 201 Created and the network ID", func() { + request := types.NewCreateNetworkRequest(testNetwork, withDefaultOptions()...) + + httpResponse := createNetwork(*request) + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusCreated)) + + response := unmarshallHTTPResponse(httpResponse) + Expect(response.ID).ShouldNot(BeEmpty()) + DeferCleanup(cleanupNetwork(response.ID)) + Expect(response.Warning).Should(BeEmpty()) + }) + }) + + When("a create network request is received with explicit non-default configuration", func() { + It("should return 201 Created and the network ID", func() { + request := types.NewCreateNetworkRequest(testNetwork, withNonDefaultOptions()...) + + httpResponse := createNetwork(*request) + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusCreated)) + + response := unmarshallHTTPResponse(httpResponse) + Expect(response.ID).ShouldNot(BeEmpty()) + DeferCleanup(cleanupNetwork(response.ID)) + Expect(response.Warning).Should(BeEmpty()) + }) + }) + + When("a network create request is made with nerdctl unsupported network options", func() { + It("should return 201 Created and the network ID", func() { + request := types.NewCreateNetworkRequest(testNetwork, withUnsupportedNetworkOptions()...) + + httpResponse := createNetwork(*request) + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusCreated)) + + response := unmarshallHTTPResponse(httpResponse) + Expect(response.ID).ShouldNot(BeEmpty()) + DeferCleanup(cleanupNetwork(response.ID)) + Expect(response.Warning).Should(BeEmpty()) + }) + }) + + When("a network create request is missing the required fields", func() { + It("should return 500 Internal Server Error", func() { + // Name is the only required field. + request := &types.NetworkCreateRequest{} + + httpResponse := createNetwork(*request) + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + }) + + When("consecutive create network requests are made with the same network name", func() { + It("should return 201 Created, the same network ID, and a warning", func() { + request := types.NewCreateNetworkRequest(testNetwork) + + httpResponse := createNetwork(*request) + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusCreated)) + + response := unmarshallHTTPResponse(httpResponse) + Expect(response.ID).ShouldNot(BeEmpty()) + Expect(response.Warning).Should(BeEmpty()) + DeferCleanup(cleanupNetwork(response.ID)) + + expected := response.ID + + httpResponse = createNetwork(*request) + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusCreated)) + + response = unmarshallHTTPResponse(httpResponse) + Expect(response.ID).ShouldNot(BeEmpty()) + Expect(response.ID).Should(Equal(expected)) + Expect(response.Warning).Should(ContainSubstring("already exists")) + }) + }) + + When("a create network request is made with an invalid JSON payload", func() { + It("should return 400 Bad Request", func() { + invalidJSON := []byte(fmt.Sprintf(`{Name: %s}`, testNetwork)) + httpResponse, err := uclient.Post(createNetworkURL, contentType, bytes.NewReader(invalidJSON)) + Expect(err).ShouldNot(HaveOccurred(), "sending HTTP request") + + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + }) + + When("a create network request is made with an unsupported network driver plugin", func() { + It("should return 404 Not Found", func() { + request := types.NewCreateNetworkRequest(testNetwork, types.WithDriver("baby")) + + httpResponse := createNetwork(*request) + Expect(httpResponse).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + }) + }) +} + +func withDefaultOptions() []types.NetworkCreateOption { + return []types.NetworkCreateOption{ + types.WithDriver("bridge"), + types.WithInternal(false), + types.WithAttachable(false), + types.WithIngress(false), + types.WithIPAM(types.IPAM{ + Driver: "default", + }), + types.WithEnableIPv6(false), + types.WithOptions(map[string]string{}), + types.WithLabels(map[string]string{}), + } +} + +func withNonDefaultOptions() []types.NetworkCreateOption { + return []types.NetworkCreateOption{ + types.WithDriver("ipvlan"), + types.WithInternal(true), + types.WithAttachable(false), + types.WithIngress(false), + types.WithIPAM(types.IPAM{ + Driver: "default", + Config: []map[string]string{ + { + "Subnet": "172.20.0.0/16", + "IPRange": "172.20.10.0/24", + "Gateway": "172.20.10.11", + }, + { + "Subnet": "2001:db8:abcd::/64", + "Gateway": "2001:db8:abcd::1011", + }, + }, + Options: map[string]string{ + "foo": "bar", + }, + }), + types.WithEnableIPv6(true), + types.WithOptions(map[string]string{ + "com.docker.network.driver.mtu": "1000", + }), + types.WithLabels(map[string]string{ + "com.example.some-label": "some-value", + "com.example.some-other-label": "some-other-value", + }), + } +} + +func withUnsupportedNetworkOptions() []types.NetworkCreateOption { + return []types.NetworkCreateOption{ + types.WithIPAM(types.IPAM{ + Driver: "default", + Config: []map[string]string{ + { + "Subnet": "240.10.0.0/24", + }, + }, + }), + types.WithOptions(map[string]string{ + "com.docker.network.bridge.enable_ip_masquerade": "true", + "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0", + "com.docker.network.bridge.name": "EvergreenPointFloatingBridge", + }), + types.WithLabels(map[string]string{ + "com.example.some-label": "some-value", + "com.example.some-other-label": "some-other-value", + }), + } +} diff --git a/e2e/tests/network_inspect.go b/e2e/tests/network_inspect.go new file mode 100644 index 00000000..3fa97d04 --- /dev/null +++ b/e2e/tests/network_inspect.go @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// NetworkInspect tests `GET networks/{id}` API +func NetworkInspect(opt *option.Option) { + Describe("inspect a network", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + It("should inspect network by network name", func() { + // create network + netId := command.StdoutStr(opt, "network", "create", testNetwork) + + // call inspect network api + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/networks/%s", testNetwork)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + // verify inspect reponse + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + var network types.NetworkInspectResponse + err = json.NewDecoder(res.Body).Decode(&network) + Expect(err).Should(BeNil()) + Expect(network.Name).Should(Equal(testNetwork)) + Expect(network.ID).Should(Equal(netId)) + Expect(network.IPAM.Config).ShouldNot(BeEmpty()) + }) + It("should inspect network by long network id", func() { + // create network + netId := command.StdoutStr(opt, "network", "create", testNetwork) + + // call inspect network api + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/networks/%s", netId)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + // verify inspect reponse + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + var network types.NetworkInspectResponse + err = json.NewDecoder(res.Body).Decode(&network) + Expect(err).Should(BeNil()) + Expect(network.Name).Should(Equal(testNetwork)) + Expect(network.ID).Should(Equal(netId)) + Expect(network.IPAM.Config).ShouldNot(BeEmpty()) + }) + It("should inspect network by short network id", func() { + // create network + netId := command.StdoutStr(opt, "network", "create", testNetwork) + + // call inspect network api + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/networks/%s", netId[:12])) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + // verify inspect reponse + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + var network types.NetworkInspectResponse + err = json.NewDecoder(res.Body).Decode(&network) + Expect(err).Should(BeNil()) + Expect(network.Name).Should(Equal(testNetwork)) + Expect(network.ID).Should(Equal(netId)) + Expect(network.IPAM.Config).ShouldNot(BeEmpty()) + }) + It("should inspect network with labels", func() { + // create network + netId := command.StdoutStr(opt, "network", "create", "--label", "testLabel=testValue", testNetwork) + + // call inspect network api + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/networks/%s", testNetwork)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + // verify inspect reponse + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + var network types.NetworkInspectResponse + err = json.NewDecoder(res.Body).Decode(&network) + Expect(err).Should(BeNil()) + Expect(network.Name).Should(Equal(testNetwork)) + Expect(network.ID).Should(Equal(netId)) + Expect(network.Labels).Should(Equal(map[string]string{"testLabel": "testValue"})) + Expect(network.IPAM.Config).ShouldNot(BeEmpty()) + }) + It("should fail to inspect nonexistent network", func() { + // call inspect network api + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/networks/%s", testNetwork)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + // expect 404 reponse + Expect(res).To(HaveHTTPStatus(http.StatusNotFound)) + var message response.Error + err = json.NewDecoder(res.Body).Decode(&message) + Expect(err).Should(BeNil()) + Expect(message.Message).Should(Equal(fmt.Sprintf("network %s not found", testNetwork))) + }) + }) +} diff --git a/e2e/tests/network_list.go b/e2e/tests/network_list.go new file mode 100644 index 00000000..594f55cc --- /dev/null +++ b/e2e/tests/network_list.go @@ -0,0 +1,66 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "io" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// NetworkList tests calling the get networks api +func NetworkList(opt *option.Option) { + Describe("lists the networks", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + It("should return bridge network by default", func() { + relativeUrl := client.ConvertToFinchUrl(version, "/networks") + + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + ls := new([]*types.NetworkInspectResponse) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + err = json.Unmarshal(body, ls) + Expect(err).Should(BeNil()) + Expect((*ls)[0].Name).Should(Equal("bridge")) + }) + It("should return a list with a new network", func() { + expName := "test-net" + command.Run(opt, "network", "create", expName) + relativeUrl := client.ConvertToFinchUrl(version, "/networks") + + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + ls := new([]*types.NetworkInspectResponse) + body, err := io.ReadAll(res.Body) + Expect(err).Should(BeNil()) + err = json.Unmarshal(body, ls) + Expect(err).Should(BeNil()) + Expect((*ls)[1].Name).Should(Equal(expName)) + }) + }) +} diff --git a/e2e/tests/network_remove.go b/e2e/tests/network_remove.go new file mode 100644 index 00000000..8bca4ebf --- /dev/null +++ b/e2e/tests/network_remove.go @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "fmt" + "net/http" + "net/http/httputil" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" +) + +func NetworkRemove(opt *option.Option) { + Describe("remove a network", func() { + var ( + uClient *http.Client + version string + apiUrl string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + relativeUrl := fmt.Sprintf("/networks/%s", testNetwork) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + It("should remove the network by name", func() { + command.Run(opt, "network", "create", testNetwork) + req, err := http.NewRequest(http.MethodDelete, apiUrl, nil) + Expect(err).Should(BeNil()) + res, err := uClient.Do(req) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + }) + It("should remove the network by id", func() { + networkId := command.StdoutStr(opt, "network", "create", testNetwork) + relativeUrl := fmt.Sprintf("/networks/%s", networkId) + apiUrl = client.ConvertToFinchUrl(version, relativeUrl) + req, err := http.NewRequest(http.MethodDelete, apiUrl, nil) + Expect(err).Should(BeNil()) + res, err := uClient.Do(req) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + }) + It("should not remove a network in use", func() { + command.Run(opt, "network", "create", testNetwork) + command.Run(opt, "run", "-d", "--network", testNetwork, defaultImage, "sleep", "infinity") + req, err := http.NewRequest(http.MethodDelete, apiUrl, nil) + Expect(err).Should(BeNil()) + res, err := uClient.Do(req) + defer res.Body.Close() + body, err := httputil.DumpResponse(res, true) + Expect(err).Should(BeNil()) + Expect(body).Should(ContainSubstring("\"test-network\\\" is in use by container")) + Expect(res.StatusCode).Should(Equal(http.StatusForbidden)) + }) + It("should return an error when network is not found", func() { + command.Run(opt, "network", "create", "notfound") + req, err := http.NewRequest(http.MethodDelete, apiUrl, nil) + Expect(err).Should(BeNil()) + res, err := uClient.Do(req) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + }) + }) +} diff --git a/e2e/tests/tests.go b/e2e/tests/tests.go new file mode 100644 index 00000000..97d4750e --- /dev/null +++ b/e2e/tests/tests.go @@ -0,0 +1,225 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package tests contains the exported functions that are meant to be imported as test cases. +// +// It should not export any other thing except for a SubcommandOption struct (e.g., LoginOption) that may be added in the future. +// +// Each file contains one subcommand to test and is named after that subcommand. +// Note that the file names are not suffixed with _test so that they can appear in Go Doc. +package tests + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/ffs" + "github.com/runfinch/common-tests/fnet" + "github.com/runfinch/common-tests/option" +) + +const ( + alpineImage = "public.ecr.aws/docker/library/alpine:latest" + olderAlpineImage = "public.ecr.aws/docker/library/alpine:3.13" + amazonLinux2Image = "public.ecr.aws/amazonlinux/amazonlinux:2" + nginxImage = "public.ecr.aws/docker/library/nginx:latest" + testImageName = "test:tag" + nonexistentImageName = "ne-repo:ne-tag" + nonexistentContainerName = "ne-ctr" + testContainerName = "ctr-test" + testContainerName2 = "ctr-test-2" + testVolumeName = "testVol" + testVolumeName2 = "anotherTestVol" + registryImage = "public.ecr.aws/docker/library/registry:latest" + localRegistryName = "local-registry" + testUser = "testUser" + testPassword = "testPassword" + sha256RegexFull = "^sha256:[a-f0-9]{64}$" + bridgeNetwork = "bridge" + testNetwork = "test-network" +) + +var defaultImage = alpineImage + +// CGMode is the cgroups mode of the host system. +// We copy the struct from containerd/cgroups [1] instead of using it as a library +// because it only builds on linux, +// while we don't really need the functions that make it only build on linux +// (e.g., determine the cgroup version of the current host). +// +// [1] https://github.com/containerd/cgroups/blob/cc78c6c1e32dc5bde018d92999910fdace3cfa27/utils.go#L38-L50 +type CGMode int + +const ( + // Unavailable cgroup mountpoint. + Unavailable CGMode = iota + // Legacy cgroups v1. + Legacy + // Hybrid with cgroups v1 and v2 controllers mounted. + Hybrid + // Unified with only cgroups v2 mounted. + Unified +) + +// SetupLocalRegistry can be invoked before running the tests to save time when pulling defaultImage. +// +// It spins up a local registry, tags the alpine image, pushes the tagged image to local registry, +// and changes defaultImage to be the one pushed to local registry. +// +// After all the tests are done, invoke CleanupLocalRegistry to clean up the local registry. +func SetupLocalRegistry(opt *option.Option) { + command.RemoveAll(opt) + hostPort := fnet.GetFreePort() + containerID := command.StdoutStr(opt, "run", "-d", "-p", + fmt.Sprintf("%d:5000", hostPort), "--name", localRegistryName, registryImage) + imageID := command.StdoutStr(opt, "images", "-q") + command.SetLocalRegistryContainerID(containerID) + command.SetLocalRegistryImageID(imageID) + command.SetLocalRegistryImageName(registryImage) + + command.Run(opt, "pull", alpineImage) + defaultImage = fmt.Sprintf("localhost:%d/alpine:latest", hostPort) + command.Run(opt, "tag", alpineImage, defaultImage) + command.Run(opt, "push", defaultImage) + command.Run(opt, "rmi", alpineImage) +} + +// CleanupLocalRegistry removes the local registry container and image. It's used together with SetupLocalRegistry, +// and should be invoked after running all the tests. +func CleanupLocalRegistry(opt *option.Option) { + containerID := command.StdoutStr(opt, "inspect", localRegistryName, "--format", "{{.ID}}") + command.Run(opt, "rm", "-f", containerID) + imageID := command.StdoutStr(opt, "images", "-q") + command.Run(opt, "rmi", "-f", imageID) +} + +func pullImage(opt *option.Option, imageName string) string { + command.Run(opt, "pull", "-q", imageName) + imageID := command.Stdout(opt, "images", "--quiet", imageName) + gomega.Expect(imageID).ShouldNot(gomega.BeEmpty()) + return strings.TrimSpace(string(imageID)) +} + +func removeImage(opt *option.Option, imageName string) { + command.Run(opt, "rmi", "--force", imageName) + imageID := command.Stdout(opt, "images", "--quiet", imageName) + gomega.Expect(string(imageID)).Should(gomega.BeEmpty()) +} + +func containerShouldBeRunning(opt *option.Option, containerNames ...string) { + for _, containerName := range containerNames { + gomega.Expect(command.Stdout(opt, "ps", "-q", "--filter", + fmt.Sprintf("name=%s", containerName))).NotTo(gomega.BeEmpty()) + } +} + +func containerShouldNotBeRunning(opt *option.Option, containerNames ...string) { + for _, containerName := range containerNames { + gomega.Expect(command.Stdout(opt, "ps", "-q", "--filter", + fmt.Sprintf("name=%s", containerName))).To(gomega.BeEmpty()) + } +} + +func containerShouldExist(opt *option.Option, containerNames ...string) { + for _, containerName := range containerNames { + gomega.Expect(command.Stdout(opt, "ps", "-a", "-q", "--filter", + fmt.Sprintf("name=%s", containerName))).NotTo(gomega.BeEmpty()) + } +} + +func containerShouldNotExist(opt *option.Option, containerNames ...string) { + for _, containerName := range containerNames { + gomega.Expect(command.Stdout(opt, "ps", "-a", "-q", "--filter", + fmt.Sprintf("name=%s", containerName))).To(gomega.BeEmpty()) + } +} + +func imageShouldExist(opt *option.Option, imageName string) { + gomega.Expect(command.Stdout(opt, "images", "-q", imageName)).NotTo(gomega.BeEmpty()) +} + +func imageShouldNotExist(opt *option.Option, imageName string) { + gomega.Expect(command.Stdout(opt, "images", "-q", imageName)).To(gomega.BeEmpty()) +} + +func volumeShouldExist(opt *option.Option, volumeName string) { + gomega.Expect(command.Stdout(opt, "volume", "ls", "-q", "--filter", + fmt.Sprintf("name=%s", volumeName))).NotTo(gomega.BeEmpty()) +} + +func volumeShouldNotExist(opt *option.Option, volumeName string) { + gomega.Expect(command.Stdout(opt, "volume", "ls", "-q", "--filter", + fmt.Sprintf("name=%s", volumeName))).To(gomega.BeEmpty()) +} + +func fileShouldExist(path, content string) { + gomega.Expect(path).To(gomega.BeARegularFile()) + actualContent, err := os.ReadFile(filepath.Clean(path)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(actualContent)).To(gomega.Equal(content)) +} + +func fileShouldNotExist(path string) { + gomega.Expect(path).ToNot(gomega.BeAnExistingFile()) +} + +func fileShouldExistInContainer(opt *option.Option, containerName, path, content string) { + gomega.Expect(command.StdoutStr(opt, "exec", containerName, "cat", path)).To(gomega.Equal(content)) +} + +func fileShouldNotExistInContainer(opt *option.Option, containerName, path string) { + cmdOut := command.RunWithoutSuccessfulExit(opt, "exec", containerName, "cat", path) + gomega.Expect(cmdOut.Err.Contents()).To(gomega.ContainSubstring("No such file or directory")) +} + +func buildImage(opt *option.Option, imageName string) { + dockerfile := fmt.Sprintf(`FROM %s + CMD ["echo", "finch-test-dummy-output"] + `, defaultImage) + buildContext := ffs.CreateBuildContext(dockerfile) + ginkgo.DeferCleanup(os.RemoveAll, buildContext) + command.Run(opt, "build", "-q", "-t", imageName, buildContext) +} + +func GetDockerHostUrl() string { + dockerHost := os.Getenv("DOCKER_HOST") + if dockerHost == "" { + panic("DOCKER_HOST not set") + } + return dockerHost +} + +func GetDockerApiVersion() string { + version := os.Getenv("DOCKER_API_VERSION") + if version == "" { + panic("DOCKER_API_VERSION not set") + } + return version +} + +// Get finch executable path from FINCH_ROOT environment variable, if set +func GetFinchExe() string { + finchdir := os.Getenv("FINCH_ROOT") + + // use default binary if env is not set + if finchdir == "" { + finchexe, err := exec.LookPath("finch") + if err != nil { + panic(err.Error()) + } + return finchexe + } + + finchexe := filepath.Join(finchdir, "bin/finch") + if _, err := os.Stat(finchexe); errors.Is(err, os.ErrNotExist) { + panic(fmt.Sprintf("%s not found. Is Finch installed?", finchexe)) + } + return finchexe +} diff --git a/e2e/tests/version.go b/e2e/tests/version.go new file mode 100644 index 00000000..e932980b --- /dev/null +++ b/e2e/tests/version.go @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// SystemVersion tests the `Get /version` API. +func SystemVersion(opt *option.Option) { + Describe("version API", func() { + var ( + uClient *http.Client + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + command.RemoveAll(opt) + }) + It("should successfully get the version info", func() { + res, err := uClient.Get(client.ConvertToFinchUrl("", "/version")) + jd := json.NewDecoder(res.Body) + var v types.VersionInfo + err = jd.Decode(&v) + Expect(err).ShouldNot(HaveOccurred()) + Expect(v.Version).ShouldNot(BeNil()) + Expect(v.Platform.Name).ShouldNot(BeEmpty()) + Expect(v.GitCommit).ShouldNot(BeEmpty()) + Expect(v.ApiVersion).Should(Equal("1.43")) + Expect(v.MinAPIVersion).Should(Equal("1.35")) + Expect(v.Components).ShouldNot(BeEmpty()) + Expect(v.Experimental).Should(BeFalse()) + Expect(strings.ToLower(v.Os)).Should(Equal("linux")) + Expect(strings.ToLower(v.Arch)).Should(Or(Equal("x86_64"), Equal(("aarch64")))) + Expect(v.KernelVersion).ShouldNot(BeEmpty()) + }) + }) +} diff --git a/e2e/tests/volume_inspect.go b/e2e/tests/volume_inspect.go new file mode 100644 index 00000000..3c7a49ac --- /dev/null +++ b/e2e/tests/volume_inspect.go @@ -0,0 +1,66 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "net/http" + + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +// VolumeInspect tests volume inspect API - GET /volumes/{volume_name} +func VolumeInspect(opt *option.Option) { + Describe("Inspect volume API", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + It("should return volume details", func() { + command.Run(opt, "volume", "create", testVolumeName, "--label", "foo=bar") + volumeShouldExist(opt, testVolumeName) + + apiUrl := client.ConvertToFinchUrl(version, "/volumes/"+testVolumeName) + res, err := uClient.Get(apiUrl) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // Read response and ensure correct volume inspected. + var volumesResp native.Volume + err = json.NewDecoder(res.Body).Decode(&volumesResp) + Expect(err).Should(BeNil()) + Expect(volumesResp.Name).Should(Equal(testVolumeName)) + Expect(*volumesResp.Labels).Should(HaveKeyWithValue("foo", "bar")) + }) + It("should return not found error", func() { + // dont create the volume and try to get the details. + apiUrl := client.ConvertToFinchUrl(version, "/volumes/"+testVolumeName) + res, err := uClient.Get(apiUrl) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNotFound)) + + // Read response and ensure not found error is returned. + var errRes response.Error + err = json.NewDecoder(res.Body).Decode(&errRes) + Expect(err).Should(BeNil()) + Expect(errRes.Message).Should(Not(BeEmpty())) + }) + + }) +} diff --git a/e2e/tests/volume_list.go b/e2e/tests/volume_list.go new file mode 100644 index 00000000..57274eba --- /dev/null +++ b/e2e/tests/volume_list.go @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// VolumeList tests listing volumes. +func VolumeList(opt *option.Option) { + Describe("list volumes", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + command.Run(opt, "volume", "create", testVolumeName, "--label", "foo=bar") + command.Run(opt, "volume", "create", testVolumeName2, "--label", "baz=biz") + volumeShouldExist(opt, testVolumeName) + volumeShouldExist(opt, testVolumeName2) + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + It("should list volumes", func() { + url := client.ConvertToFinchUrl(version, "/volumes") + res, err := uClient.Get(url) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // Read response and ensure more than one volume listed. + body, err := ioutil.ReadAll(res.Body) + Expect(err).Should(BeNil()) + defer res.Body.Close() + var volumesResp types.VolumesListResponse + err = json.Unmarshal(body, &volumesResp) + Expect(err).Should(BeNil()) + Expect(len(volumesResp.Volumes)).Should(BeNumerically(">", 0)) + }) + It("should list volumes and filter them", func() { + urlEncodedJsonFilter := url.QueryEscape(`{"labels": ["foo"]}`) + relativeUrl := fmt.Sprintf("/volumes?filters=%s", urlEncodedJsonFilter) + url := client.ConvertToFinchUrl(version, relativeUrl) + res, err := uClient.Get(url) + + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusOK)) + + // Read response and expect len to be 1, volume name to be + // the testVolumeName we filtered for. + body, err := ioutil.ReadAll(res.Body) + Expect(err).Should(BeNil()) + defer res.Body.Close() + + var volumesResp types.VolumesListResponse + err = json.Unmarshal(body, &volumesResp) + Expect(err).Should(BeNil()) + Expect(len(volumesResp.Volumes)).Should(Equal(1)) + Expect(volumesResp.Volumes[0].Name).Should(Equal(testVolumeName)) + }) + }) +} diff --git a/e2e/tests/volume_remove.go b/e2e/tests/volume_remove.go new file mode 100644 index 00000000..fde7279c --- /dev/null +++ b/e2e/tests/volume_remove.go @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/finch-daemon/e2e/client" +) + +// VolumeRemove tests the volume remove API +func VolumeRemove(opt *option.Option) { + Describe("Remove volume API", func() { + var ( + uClient *http.Client + version string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + AfterEach(func() { + command.RemoveAll(opt) + }) + It("should remove a volume", func() { + command.Run(opt, "volume", "create", testVolumeName) + volumeShouldExist(opt, testVolumeName) + apiUrl := client.ConvertToFinchUrl(version, "/volumes/"+testVolumeName) + req, err := http.NewRequest("DELETE", apiUrl, nil) + Expect(err).Should(BeNil()) + res, err := uClient.Do(req) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + volumeShouldNotExist(opt, testVolumeName) + + }) + It("should remove a volume with force=true", func() { + command.Run(opt, "volume", "create", testVolumeName) + volumeShouldExist(opt, testVolumeName) + apiUrl := client.ConvertToFinchUrl(version, "/volumes/"+testVolumeName+"?force=true") + req, err := http.NewRequest("DELETE", apiUrl, nil) + Expect(err).Should(BeNil()) + res, err := uClient.Do(req) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusNoContent)) + volumeShouldNotExist(opt, testVolumeName) + + }) + It("should fail to remove a volume that is in use", func() { + command.Run(opt, "run", "-d", "--name", testContainerName, "-v", testVolumeName+":/data", + defaultImage, "sleep", "infinity") + apiUrl := client.ConvertToFinchUrl(version, "/volumes/"+testVolumeName) + req, err := http.NewRequest("DELETE", apiUrl, nil) + Expect(err).Should(BeNil()) + res, err := uClient.Do(req) + Expect(err).Should(BeNil()) + Expect(res.StatusCode).Should(Equal(http.StatusConflict)) + volumeShouldExist(opt, testVolumeName) + }) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..5ba2d990 --- /dev/null +++ b/go.mod @@ -0,0 +1,150 @@ +module github.com/runfinch/finch-daemon + +go 1.21.0 + +require ( + github.com/containerd/cgroups/v3 v3.0.3 + github.com/containerd/containerd v1.7.14 + github.com/containerd/go-cni v1.1.9 + github.com/containerd/nerdctl v1.7.5 + github.com/containerd/typeurl/v2 v2.1.1 + github.com/containernetworking/cni v1.1.2 + github.com/coreos/go-systemd/v22 v22.5.0 + github.com/docker/cli v26.0.0+incompatible + github.com/docker/docker v26.0.0+incompatible + github.com/docker/go-connections v0.5.0 + github.com/getlantern/httptest v0.0.0-20161025015934-4b40f4c7e590 + github.com/golang/mock v1.6.0 + github.com/gorilla/handlers v1.5.2 + github.com/gorilla/mux v1.8.1 + github.com/moby/moby v26.0.0+incompatible + github.com/onsi/ginkgo/v2 v2.17.1 + github.com/onsi/gomega v1.32.0 + github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.0 + github.com/opencontainers/runtime-spec v1.2.0 + github.com/pkg/errors v0.9.1 + github.com/runfinch/common-tests v0.7.21 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/afero v1.11.0 + github.com/spf13/cobra v1.8.0 + github.com/vishvananda/netlink v1.2.1-beta.2 + github.com/vishvananda/netns v0.0.4 + golang.org/x/net v0.22.0 + golang.org/x/sys v0.18.0 + google.golang.org/protobuf v1.33.0 +) + +require ( + github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect + github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/hcsshim v0.12.2 // indirect + github.com/awslabs/soci-snapshotter v0.5.0 // indirect + github.com/cilium/ebpf v0.14.0 // indirect + github.com/containerd/accelerated-container-image v1.0.4 // indirect + github.com/containerd/console v1.0.4 // indirect + github.com/containerd/continuity v0.4.3 // indirect + github.com/containerd/errdefs v0.1.0 // indirect + github.com/containerd/fifo v1.1.0 // indirect + github.com/containerd/go-runc v1.1.0 // indirect + github.com/containerd/imgcrypt v1.1.10 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/nydus-snapshotter v0.13.11 // indirect + github.com/containerd/stargz-snapshotter v0.15.1 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect + github.com/containerd/stargz-snapshotter/ipfs v0.15.1 // indirect + github.com/containerd/ttrpc v1.2.3 // indirect + github.com/containerd/typeurl v1.0.3-0.20220422153119-7f6e6d160d67 // indirect + github.com/containernetworking/plugins v1.4.1 // indirect + github.com/containers/ocicrypt v1.1.10 // indirect + github.com/coreos/go-iptables v0.7.0 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/djherbis/times v1.6.0 // indirect + github.com/docker/docker-credential-helpers v0.8.1 // indirect + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/fahedouch/go-logrotate v0.2.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fluent/fluent-logger-golang v1.9.0 // indirect + github.com/getlantern/mockconn v0.0.0-20200818071412-cb30d065a848 // indirect + github.com/go-jose/go-jose/v3 v3.0.3 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20240327155427-868f304927ed // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/ipfs/go-cid v0.4.1 // indirect + github.com/klauspost/compress v1.17.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/locker v1.0.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/mount v0.3.3 // indirect + github.com/moby/sys/mountinfo v0.7.1 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/signal v0.7.0 // indirect + github.com/moby/sys/symlink v0.2.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multiaddr v0.12.3 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect + github.com/opencontainers/selinux v1.11.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/philhofer/fwd v1.1.2 // indirect + github.com/rootless-containers/bypass4netns v0.4.0 // indirect + github.com/rootless-containers/rootlesskit v1.1.1 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect + github.com/tidwall/gjson v1.17.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tinylib/msgp v1.1.9 // indirect + github.com/vbatts/tar-split v0.11.5 // indirect + github.com/yuchanns/srslog v1.1.0 // indirect + go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/mod v0.16.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/term v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.19.0 // indirect + google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect + google.golang.org/grpc v1.62.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/cri-api v0.29.3 // indirect + lukechampine.com/blake3 v1.2.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..f0e4e8e3 --- /dev/null +++ b/go.sum @@ -0,0 +1,528 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2 h1:dIScnXFlF784X79oi7MzVT6GWqr/W1uUt0pB5CsDs9M= +github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2/go.mod h1:gCLVsLfv1egrcZu+GoJATN5ts75F2s62ih/457eWzOw= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.12.2 h1:AcXy+yfRvrx20g9v7qYaJv5Rh+8GaHOS6b8G6Wx/nKs= +github.com/Microsoft/hcsshim v0.12.2/go.mod h1:RZV12pcHCXQ42XnlQ3pz6FZfmrC1C+R4gaOHhRNML1g= +github.com/awslabs/soci-snapshotter v0.5.0 h1:Ah8L6AWIqknz8wIVl7saHRpMeJSOWWC6pwZQ9qCG7Tw= +github.com/awslabs/soci-snapshotter v0.5.0/go.mod h1:x3yL5YDlNdBfSZnGTccnV5eVITaHaQLkg9QVnql1HQQ= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cilium/ebpf v0.14.0 h1:0PsxAjO6EjI1rcT+rkp6WcCnE0ZvfkXBYiMedJtrSUs= +github.com/cilium/ebpf v0.14.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/containerd/accelerated-container-image v1.0.4 h1:2WDo44n7ohyDeqkynC1C8BboaZrrIIICGdmunz0jCXs= +github.com/containerd/accelerated-container-image v1.0.4/go.mod h1:iPvBVzJWG0WbfBEGk4Ap+HLWPaUWnx4toLpVkBafIDI= +github.com/containerd/cgroups/v3 v3.0.3 h1:S5ByHZ/h9PMe5IOQoN7E+nMc2UcLEM/V48DGDJ9kip0= +github.com/containerd/cgroups/v3 v3.0.3/go.mod h1:8HBe7V3aWGLFPd/k03swSIsGjZhHI2WzJmticMgVuz0= +github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= +github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/containerd/containerd v1.7.14 h1:H/XLzbnGuenZEGK+v0RkwTdv2u1QFAruMe5N0GNPJwA= +github.com/containerd/containerd v1.7.14/go.mod h1:YMC9Qt5yzNqXx/fO4j/5yYVIHXSRrlB3H7sxkUTvspg= +github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= +github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/containerd/errdefs v0.1.0 h1:m0wCRBiu1WJT/Fr+iOoQHMQS/eP5myQ8lCv4Dz5ZURM= +github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0= +github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= +github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= +github.com/containerd/go-cni v1.1.9 h1:ORi7P1dYzCwVM6XPN4n3CbkuOx/NZ2DOqy+SHRdo9rU= +github.com/containerd/go-cni v1.1.9/go.mod h1:XYrZJ1d5W6E2VOvjffL3IZq0Dz6bsVlERHbekNK90PM= +github.com/containerd/go-runc v1.1.0 h1:OX4f+/i2y5sUT7LhmcJH7GYrjjhHa1QI4e8yO0gGleA= +github.com/containerd/go-runc v1.1.0/go.mod h1:xJv2hFF7GvHtTJd9JqTS2UVxMkULUYw4JN5XAUZqH5U= +github.com/containerd/imgcrypt v1.1.10 h1:vtyGzTna2wC5BSQcqHWgY4xsBLHWFWyecgK0+9Np8aE= +github.com/containerd/imgcrypt v1.1.10/go.mod h1:9eIPG34EQy+j00fr+4r0knul2MkYDKD2uzKkw8548aw= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/nerdctl v1.7.5 h1:vZhwrNuTzFfLWs7M6/WYLZIfz9lZ2JIGAOfp+5Nenzs= +github.com/containerd/nerdctl v1.7.5/go.mod h1:NFw889YiQVZqhpCbsRet6TObQVdgNb6Tq3HeKhPIAyI= +github.com/containerd/nydus-snapshotter v0.13.11 h1:0euz1viJ0/4sZ5P0GP28wKrd+m0YqKRQcM6GZjuSKZk= +github.com/containerd/nydus-snapshotter v0.13.11/go.mod h1:VPVKQ3jmHFIcUIV2yiQ1kImZuBFS3GXDohKs9mRABVE= +github.com/containerd/stargz-snapshotter v0.15.1 h1:fpsP4kf/Z4n2EYnU0WT8ZCE3eiKDwikDhL6VwxIlgeA= +github.com/containerd/stargz-snapshotter v0.15.1/go.mod h1:74D+J1m1RMXytLmWxegXWhtOSRHPWZKpKc2NdK3S+us= +github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU= +github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk= +github.com/containerd/stargz-snapshotter/ipfs v0.15.1 h1:MMWRYrTu2iVOn9eRJqEer7v0eg34xY2uFZxbrrm2iCY= +github.com/containerd/stargz-snapshotter/ipfs v0.15.1/go.mod h1:DvrczCWAJnbTOau8txguZXDZdA7r39O3/Aj2olx+Q90= +github.com/containerd/ttrpc v1.2.3 h1:4jlhbXIGvijRtNC8F/5CpuJZ7yKOBFGFOOXg1bkISz0= +github.com/containerd/ttrpc v1.2.3/go.mod h1:ieWsXucbb8Mj9PH0rXCw1i8IunRbbAiDkpXkbfflWBM= +github.com/containerd/typeurl v1.0.3-0.20220422153119-7f6e6d160d67 h1:rQvjv7gRi6Ki/NS/U9oLZFhqyk4dh/GH2M3o/4BRkMM= +github.com/containerd/typeurl v1.0.3-0.20220422153119-7f6e6d160d67/go.mod h1:HDkcKOXRnX6yKnXv3P0QrogFi0DoiauK/LpQi961f0A= +github.com/containerd/typeurl/v2 v2.1.1 h1:3Q4Pt7i8nYwy2KmQWIw2+1hTvwTE/6w9FqcttATPO/4= +github.com/containerd/typeurl/v2 v2.1.1/go.mod h1:IDp2JFvbwZ31H8dQbEIY7sDl2L3o3HZj1hsSQlywkQ0= +github.com/containernetworking/cni v1.1.2 h1:wtRGZVv7olUHMOqouPpn3cXJWpJgM6+EUl31EQbXALQ= +github.com/containernetworking/cni v1.1.2/go.mod h1:sDpYKmGVENF3s6uvMvGgldDWeG8dMxakj/u+i9ht9vw= +github.com/containernetworking/plugins v1.4.1 h1:+sJRRv8PKhLkXIl6tH1D7RMi+CbbHutDGU+ErLBORWA= +github.com/containernetworking/plugins v1.4.1/go.mod h1:n6FFGKcaY4o2o5msgu/UImtoC+fpQXM3076VHfHbj60= +github.com/containers/ocicrypt v1.1.10 h1:r7UR6o8+lyhkEywetubUUgcKFjOWOaWz8cEBrCPX0ic= +github.com/containers/ocicrypt v1.1.10/go.mod h1:YfzSSr06PTHQwSTUKqDSjish9BeW1E4HUmreluQcMd8= +github.com/coreos/go-iptables v0.7.0 h1:XWM3V+MPRr5/q51NuWSgU0fqMad64Zyxs8ZUoMsamr8= +github.com/coreos/go-iptables v0.7.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/docker/cli v26.0.0+incompatible h1:90BKrx1a1HKYpSnnBFR6AgDq/FqkHxwlUyzJVPxD30I= +github.com/docker/cli v26.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v26.0.0+incompatible h1:Ng2qi+gdKADUa/VM+6b6YaY2nlZhk/lVJiKR/2bMudU= +github.com/docker/docker v26.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo= +github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fahedouch/go-logrotate v0.2.0 h1:UR9Fv8MDVfWwnkirmFHck+tRSWzqOwRjVRLMpQgSxaI= +github.com/fahedouch/go-logrotate v0.2.0/go.mod h1:1RL/yr7LntS4zadAC6FT6yB/C1CQt3V6eHAZzymfwzE= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluent/fluent-logger-golang v1.9.0 h1:zUdY44CHX2oIUc7VTNZc+4m+ORuO/mldQDA7czhWXEg= +github.com/fluent/fluent-logger-golang v1.9.0/go.mod h1:2/HCT/jTy78yGyeNGQLGQsjF3zzzAuy6Xlk6FCMV5eU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getlantern/httptest v0.0.0-20161025015934-4b40f4c7e590 h1:OhyiFx+yBN30O3IHrIq+9LAEhy6o7fin21wUQxF8NiE= +github.com/getlantern/httptest v0.0.0-20161025015934-4b40f4c7e590/go.mod h1:rE/jidqqHHG9sjSxC24Gd5YCfZ1AT91C2wjJ28TAOfA= +github.com/getlantern/mockconn v0.0.0-20200818071412-cb30d065a848 h1:2MhMMVBTnaHrst6HyWFDhwQCaJ05PZuOv1bE2gN8WFY= +github.com/getlantern/mockconn v0.0.0-20200818071412-cb30d065a848/go.mod h1:+F5GJ7qGpQ03DBtcOEyQpM30ix4BLswdaojecFtsdy8= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= +github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20240327155427-868f304927ed h1:n8QtJTrwsv3P7dNxPaMeNkMcxvUpqocsHLr8iDLGlQI= +github.com/google/pprof v0.0.0-20240327155427-868f304927ed/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= +github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +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/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= +github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/moby v26.0.0+incompatible h1:2n9/cIWkxiEI1VsWgTGgXhxIWUbv42PyxEP9L+RReC0= +github.com/moby/moby v26.0.0+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/mount v0.3.3 h1:fX1SVkXFJ47XWDoeFW4Sq7PdQJnV2QIDZAqjNqgEjUs= +github.com/moby/sys/mount v0.3.3/go.mod h1:PBaEorSNTLG5t/+4EgukEQVlAvVEc6ZjTySwKdqp5K0= +github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/sys/mountinfo v0.7.1 h1:/tTvQaSJRr2FshkhXiIpux6fQ2Zvc4j7tAhMTStAG2g= +github.com/moby/sys/mountinfo v0.7.1/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI= +github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= +github.com/moby/sys/symlink v0.2.0 h1:tk1rOM+Ljp0nFmfOIBtlV3rTDlWOwFRhjEeAhZB0nZc= +github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multiaddr v0.12.3 h1:hVBXvPRcKG0w80VinQ23P5t7czWgg65BmIvQKjDydU8= +github.com/multiformats/go-multiaddr v0.12.3/go.mod h1:sBXrNzucqkFJhvKOiwwLyqamGa/P5EIXNPLovyhQCII= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= +github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= +github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= +github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= +github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rootless-containers/bypass4netns v0.4.0 h1:7pcI4XWnOMQkgCsPKMXxMzQKhZUjaQ8J1n+eIYiHS0Y= +github.com/rootless-containers/bypass4netns v0.4.0/go.mod h1:RPNWMSRT951DMtq9Xv72IZoJPWFeJL6Wg5pF79Lkano= +github.com/rootless-containers/rootlesskit v1.1.1 h1:F5psKWoWY9/VjZ3ifVcaosjvFZJOagX85U22M0/EQZE= +github.com/rootless-containers/rootlesskit v1.1.1/go.mod h1:UD5GoA3dqKCJrnvnhVgQQnweMF2qZnf9KLw8EewcMZI= +github.com/runfinch/common-tests v0.7.21 h1:T7WccN396JeDTfgMQZT1PqTOsuNdRL/FMCskYiY7Itg= +github.com/runfinch/common-tests v0.7.21/go.mod h1:Gp3zzIUg1B0gM8TpiAlPxZpbyZneKyRFyBJ6PLODrOQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +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/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw= +github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU= +github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k= +github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts= +github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk= +github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= +github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/yuchanns/srslog v1.1.0 h1:CEm97Xxxd8XpJThE0gc/XsqUGgPufh5u5MUjC27/KOk= +github.com/yuchanns/srslog v1.1.0/go.mod h1:HsLjdv3XV02C3kgBW2bTyW6i88OQE+VYJZIxrPKPPak= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 h1:CCriYyAfq1Br1aIYettdHZTy8mBTIPo7We18TuO/bak= +go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= +go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +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/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +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/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +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= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda h1:wu/KJm9KJwpfHWhkkZGohVC6KRrc1oJNr4jwtQMOQXw= +google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda/go.mod h1:g2LLCvCeCSir/JJSWosk19BR4NVxGqHUC6rxIRsd7Aw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/cri-api v0.29.3 h1:ppKSui+hhTJW774Mou6x+/ealmzt2jmTM0vsEQVWrjI= +k8s.io/cri-api v0.29.3/go.mod h1:3X7EnhsNaQnCweGhQCJwKNHlH7wHEYuKQ19bRvXMoJY= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/pkg/api/auth/auth.go b/pkg/api/auth/auth.go new file mode 100644 index 00000000..77adafa1 --- /dev/null +++ b/pkg/api/auth/auth.go @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auth + +// Copied from https://github.com/moby/moby/blob/master/api/types/registry/authconfig.go +// TODO: Revisit this as this to remove things that don't make sense to Finch. +// Likely we should move this file to some auth package as it should be used by more than /push +// (e.g., pulling an image from a private registry). + +import ( + "encoding/base64" + "encoding/json" + "io" + "strings" + + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/pkg/errors" +) + +// AuthHeader is the name of the header used to send encoded registry +// authorization credentials for registry operations (push/pull). +const AuthHeader = "X-Registry-Auth" + +// DecodeAuthConfig decodes base64url encoded (RFC4648, section 5) JSON +// authentication information as sent through the X-Registry-Auth header. +// +// This function always returns an AuthConfig, even if an error occurs. It is up +// to the caller to decide if authentication is required, and if the error can +// be ignored. +// +// For details on base64url encoding, see: +// - RFC4648, section 5: https://tools.ietf.org/html/rfc4648#section-5 +func DecodeAuthConfig(authEncoded string) (*dockertypes.AuthConfig, error) { + if authEncoded == "" { + return &dockertypes.AuthConfig{}, nil + } + + authJSON := base64.NewDecoder(base64.URLEncoding, strings.NewReader(authEncoded)) + return decodeAuthConfigFromReader(authJSON) +} + +// DecodeAuthConfigBody decodes authentication information as sent as JSON in the +// body of a request. This function is to provide backward compatibility with old +// clients and API versions. Current clients and API versions expect authentication +// to be provided through the X-Registry-Auth header. +// +// Like DecodeAuthConfig, this function always returns an AuthConfig, even if an +// error occurs. It is up to the caller to decide if authentication is required, +// and if the error can be ignored. +func DecodeAuthConfigBody(rdr io.ReadCloser) (*dockertypes.AuthConfig, error) { + return decodeAuthConfigFromReader(rdr) +} + +func decodeAuthConfigFromReader(rdr io.Reader) (*dockertypes.AuthConfig, error) { + authConfig := &dockertypes.AuthConfig{} + if err := json.NewDecoder(rdr).Decode(authConfig); err != nil { + // always return an (empty) AuthConfig to increase compatibility with + // the existing API. + return &dockertypes.AuthConfig{}, errors.Wrap(err, "invalid X-Registry-Auth header") + } + return authConfig, nil +} diff --git a/pkg/api/events/events.go b/pkg/api/events/events.go new file mode 100644 index 00000000..570f77f1 --- /dev/null +++ b/pkg/api/events/events.go @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package events + +import "github.com/containerd/typeurl/v2" + +const ( + CompatibleTopicPrefix = "dockercompat" +) + +// From https://github.com/moby/moby/blob/v24.0.4/api/types/events/events.go#L31-L47 +type Event struct { + Status string `json:"status,omitempty"` // Deprecated: use Action instead. + ID string `json:"id,omitempty"` // Deprecated: use Actor.ID instead. + Type string `json:"Type"` + Action string `json:"Action"` + Actor EventActor `json:"Actor"` + Scope string `json:"scope"` + Time int64 `json:"time"` + TimeNano int64 `json:"timeNano"` +} + +type EventActor struct { + Id string `json:"ID"` + Attributes map[string]string `json:"Attributes"` +} + +func init() { + typeurl.Register(&Event{}, "dockercompat.event") + typeurl.Register(&EventActor{}, "dockercompat.event.actor") +} diff --git a/pkg/api/handlers/builder/build.go b/pkg/api/handlers/builder/build.go new file mode 100644 index 00000000..888a20db --- /dev/null +++ b/pkg/api/handlers/builder/build.go @@ -0,0 +1,118 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package builder + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/containerd/containerd/namespaces" + "github.com/containerd/nerdctl/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/utility/maputility" +) + +// build function is the http handler function for /build API +func (h *handler) build(w http.ResponseWriter, r *http.Request) { + streamWriter := response.NewStreamWriter(w) + + // create the build options based on passed parameter + buildOptions, err := h.getBuildOptions(w, r, streamWriter) + if err != nil { + streamWriter.WriteError(http.StatusInternalServerError, err) + return + } + + // call the service to build + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + result, err := h.service.Build(ctx, buildOptions, r.Body) + if err != nil { + streamWriter.WriteError(http.StatusInternalServerError, err) + return + } + + // send build result as out-of-band aux data + for _, buildImage := range result { + auxData, err := json.Marshal(buildImage) + if err != nil { + return + } + streamWriter.WriteAux(auxData) + } +} + +// getBuildOptions creates the build option parameter from http request which is requires by nerdctl build function +func (h *handler) getBuildOptions(w http.ResponseWriter, r *http.Request, stream io.Writer) (*types.BuilderBuildOptions, error) { + bkHost, err := h.ncBuildSvc.GetBuildkitHost() + if err != nil { + h.logger.Warnf("Failed to get buildkit host: %v", err.Error()) + return nil, err + } + options := types.BuilderBuildOptions{ + // TODO: investigate - interestingly nerdctl prints all the build log in stderr for some reason. + Stdout: stream, + Stderr: stream, + GOptions: types.GlobalCommandOptions{ + Debug: h.Config.Debug, + Address: h.Config.Address, + Namespace: h.Config.Namespace, + Snapshotter: h.Config.Snapshotter, + DataRoot: h.Config.DataRoot, + CgroupManager: h.Config.CgroupManager, + HostsDir: h.Config.HostsDir, + }, + BuildKitHost: bkHost, + Tag: getQueryParamList(r, "t", nil), + File: getQueryParamStr(r, "dockerfile", "Dockerfile"), + Target: getQueryParamStr(r, "target", ""), + Platform: getQueryParamList(r, "platform", []string{}), + Rm: getQueryParamBool(r, "rm", true), + Progress: "auto", + } + + argsQuery := r.URL.Query().Get("buildargs") + if argsQuery != "" { + buildargs := make(map[string]string) + err := json.Unmarshal([]byte(argsQuery), &buildargs) + if err != nil { + return nil, fmt.Errorf("unable to parse buildargs query: %s", err) + } + options.BuildArgs = maputility.Flatten(buildargs, maputility.KeyEqualsValueFormat) + } + return &options, nil +} + +// getQueryParamStr fetch string query parameter and returns default value if empty +func getQueryParamStr(r *http.Request, paramName string, defaultValue string) string { + val := r.URL.Query().Get(paramName) + if val == "" { + return defaultValue + } + return val +} + +// getQueryParamBool fetch boolean query parameter and returns default value if empty +func getQueryParamBool(r *http.Request, paramName string, defaultValue bool) bool { + val := r.URL.Query().Get(paramName) + if val == "" { + return defaultValue + } + if boolValue, err := strconv.ParseBool(val); err != nil { + return defaultValue + } else { + return boolValue + } +} + +// getQueryParamList fetch list of string query parameter and returns default value if empty +func getQueryParamList(r *http.Request, paramName string, defaultValue []string) []string { + params := r.URL.Query() + if params == nil || params[paramName] == nil { + return defaultValue + } + return params[paramName] +} diff --git a/pkg/api/handlers/builder/build_test.go b/pkg/api/handlers/builder/build_test.go new file mode 100644 index 00000000..ac7e8ff7 --- /dev/null +++ b/pkg/api/handlers/builder/build_test.go @@ -0,0 +1,170 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package builder + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_builder" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Build API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_builder.MockService + ncBuildSvc *mocks_backend.MockNerdctlBuilderSvc + h *handler + rr *httptest.ResponseRecorder + stream io.Writer + req *http.Request + result []types.BuildResult + auxMsg []*json.RawMessage + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_builder.NewMockService(mockCtrl) + ncBuildSvc = mocks_backend.NewMockNerdctlBuilderSvc(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger, ncBuildSvc) + rr = httptest.NewRecorder() + stream = response.NewStreamWriter(rr) + req, _ = http.NewRequest(http.MethodPost, "/build", nil) + result = []types.BuildResult{ + {ID: "image1"}, + {ID: "image2"}, + } + + auxMsg = []*json.RawMessage{} + for _, image := range result { + auxData, err := json.Marshal(image) + Expect(err).Should(BeNil()) + rawMsg := json.RawMessage(auxData) + auxMsg = append(auxMsg, &rawMsg) + } + }) + Context("handler", func() { + It("should return 200 as success response", func() { + // service mock returns nil to mimic service built the image successfully. + service.EXPECT().Build(gomock.Any(), gomock.Any(), gomock.Any()).Return(result, nil) + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + + //handler should return success message with 204 status code. + h.build(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + + // expected stream output + scanner := bufio.NewScanner(rr.Body) + outputs := []response.StreamResponse{} + for scanner.Scan() { + var streamResp response.StreamResponse + err := json.Unmarshal(scanner.Bytes(), &streamResp) + Expect(err).Should(BeNil()) + outputs = append(outputs, streamResp) + } + Expect(len(outputs)).Should(Equal(len(result))) + for i, aux := range auxMsg { + Expect(outputs[i]).Should(Equal(response.StreamResponse{Aux: aux})) + } + }) + + It("should return 500 error", func() { + // service mock returns not found error to mimic image build failed + service.EXPECT().Build(gomock.Any(), gomock.Any(), gomock.Any()).Return( + nil, errdefs.NewNotFound(fmt.Errorf("some error"))) + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + + //handler should return 500 status code with an error msg. + h.build(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "some error"}`)) + }) + It("should return error due to buildkit failure", func() { + req = httptest.NewRequest(http.MethodPost, "/build", nil) + logger.EXPECT().Warnf("Failed to get buildkit host: %v", gomock.Any()) + ncBuildSvc.EXPECT().GetBuildkitHost().Return("", fmt.Errorf("some error")).AnyTimes() + h.build(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "some error"}`)) + }) + It("should set the buildkit host", func() { + req = httptest.NewRequest(http.MethodPost, "/build", nil) + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + buildOption, err := h.getBuildOptions(rr, req, stream) + Expect(err).Should(BeNil()) + Expect(buildOption.BuildKitHost).Should(Equal("mocked-value")) + }) + It("should fail to get build options due to buildkit error", func() { + ncBuildSvc.EXPECT().GetBuildkitHost().Return("", fmt.Errorf("some error")) + logger.EXPECT().Warnf("Failed to get buildkit host: %v", gomock.Any()) + req = httptest.NewRequest(http.MethodPost, "/build", nil) + + buildOption, err := h.getBuildOptions(rr, req, stream) + Expect(err).Should(Not(BeNil())) + Expect(buildOption).Should(BeNil()) + }) + It("should set the tag query param", func() { + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + req = httptest.NewRequest(http.MethodPost, "/build?t=tag1&t=tag2", nil) + buildOption, err := h.getBuildOptions(rr, req, stream) + Expect(err).Should(BeNil()) + Expect(buildOption.Tag).Should(ContainElements("tag1", "tag2")) + }) + It("should set the platform query param", func() { + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + req = httptest.NewRequest(http.MethodPost, "/build?platform=amd64/x86_64", nil) + buildOption, err := h.getBuildOptions(rr, req, stream) + Expect(err).Should(BeNil()) + Expect(buildOption.Platform).Should(ContainElements("amd64/x86_64")) + }) + It("should set the dockerfile query param", func() { + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + req = httptest.NewRequest(http.MethodPost, "/build?dockerfile=mydockerfile", nil) + buildOption, err := h.getBuildOptions(rr, req, stream) + Expect(err).Should(BeNil()) + Expect(buildOption.File).Should(Equal("mydockerfile")) + }) + It("should set the rm query param", func() { + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + req = httptest.NewRequest(http.MethodPost, "/build?rm=false", nil) + buildOption, err := h.getBuildOptions(rr, req, stream) + Expect(err).Should(BeNil()) + Expect(buildOption.Rm).Should(BeFalse()) + }) + It("should set the rm query param to default if invalid value is provided", func() { + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + req = httptest.NewRequest(http.MethodPost, "/build?rm=WrongType", nil) + buildOption, err := h.getBuildOptions(rr, req, stream) + Expect(err).Should(BeNil()) + Expect(buildOption.Rm).Should(BeTrue()) + }) + It("should set all the default value for the query param", func() { + ncBuildSvc.EXPECT().GetBuildkitHost().Return("mocked-value", nil).AnyTimes() + req = httptest.NewRequest(http.MethodPost, "/build", nil) + buildOption, err := h.getBuildOptions(rr, req, stream) + Expect(err).Should(BeNil()) + Expect(buildOption.Tag).Should(HaveLen(0)) + Expect(buildOption.Platform).Should(HaveLen(0)) + Expect(buildOption.File).Should(Equal("Dockerfile")) + Expect(buildOption.Rm).Should(BeTrue()) + }) + }) +}) diff --git a/pkg/api/handlers/builder/builder.go b/pkg/api/handlers/builder/builder.go new file mode 100644 index 00000000..979183e2 --- /dev/null +++ b/pkg/api/handlers/builder/builder.go @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package builder comprises functions, interfaces and structures related to build APIs +package builder + +import ( + "context" + "io" + "net/http" + + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/config" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +// RegisterHandlers register all the supported endpoints related to the container APIs +func RegisterHandlers(r types.VersionedRouter, + service Service, + conf *config.Config, + logger flog.Logger, + ncBuildSvc backend.NerdctlBuilderSvc) { + h := newHandler(service, conf, logger, ncBuildSvc) + r.HandleFunc("/build", h.build, http.MethodPost) +} + +// Service interface for build related APIs +// +//go:generate mockgen --destination=../../../mocks/mocks_builder/buildersvc.go -package=mocks_builder github.com/runfinch/finch-daemon/pkg/api/handlers/builder Service +type Service interface { + Build(ctx context.Context, options *ncTypes.BuilderBuildOptions, tarBody io.ReadCloser) ([]types.BuildResult, error) +} + +// newHandler creates the handler that serves all the container related APIs +func newHandler(service Service, conf *config.Config, logger flog.Logger, ncBuildSvc backend.NerdctlBuilderSvc) *handler { + return &handler{ + service: service, + Config: conf, + logger: logger, + ncBuildSvc: ncBuildSvc, + } +} + +type handler struct { + service Service + Config *config.Config + logger flog.Logger + ncBuildSvc backend.NerdctlBuilderSvc +} diff --git a/pkg/api/handlers/builder/builder_test.go b/pkg/api/handlers/builder/builder_test.go new file mode 100644 index 00000000..063e04a1 --- /dev/null +++ b/pkg/api/handlers/builder/builder_test.go @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package builder + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_builder" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// TestBuilderHandler function is the entry point of builder handler package's unit test using ginkgo +func TestBuilderHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Build APIs Handler") +} + +// Unit tests related to check RegisterHandlers() has configured the endpoint properly for build related API +var _ = Describe("Build API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_builder.MockService + rr *httptest.ResponseRecorder + req *http.Request + conf config.Config + router *mux.Router + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_builder.NewMockService(mockCtrl) + router = mux.NewRouter() + ncBuildSvc := mocks_backend.NewMockNerdctlBuilderSvc(mockCtrl) + RegisterHandlers(types.VersionedRouter{Router: router}, service, &conf, logger, ncBuildSvc) + rr = httptest.NewRecorder() + ncBuildSvc.EXPECT().GetBuildkitHost().Return("", nil).AnyTimes() + + }) + Context("handler", func() { + It("should call build method", func() { + // setup mocks + service.EXPECT().Build(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error from build api")) + req, _ = http.NewRequest(http.MethodPost, "/build", nil) + // call the API to check if it returns the error generated from the build method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{ "message": "error from build api"}`)) + }) + }) + +}) diff --git a/pkg/api/handlers/container/attach.go b/pkg/api/handlers/container/attach.go new file mode 100644 index 00000000..c0cac473 --- /dev/null +++ b/pkg/api/handlers/container/attach.go @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/gorilla/mux" + "github.com/moby/moby/api/server/httputils" + "github.com/moby/moby/api/types/versions" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// attach handles the http request for attaching containers through hijacking the connection +// Modified from https://github.com/moby/moby/blob/5a9201ff477dd0f855d8f7fe59e9d59c4d90ac37/api/server/router/container/container_routes.go#L643. +// +// TODO: Add "Currently only one attach session is allowed." to the API doc. +func (h *handler) attach(w http.ResponseWriter, r *http.Request) { + // setup hijacker && hijack connection + hijacker, ok := w.(http.Hijacker) + if !ok { + response.JSON(w, http.StatusBadRequest, response. + NewErrorFromMsg("the response writer is not a http.Hijacker")) + return + } + conn, _, err := hijacker.Hijack() + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + + // set raw mode + _, err = conn.Write([]byte{}) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + + // setup stop channel to communicate with logviewer, + stopChannel := make(chan os.Signal, 1) + signal.Notify(stopChannel, syscall.SIGTERM, syscall.SIGINT) + go checkConnection(conn, func() { + stopChannel <- os.Interrupt + }) + + _, upgrade := r.Header["Upgrade"] + contentType, successResponse := checkUpgradeStatus(r.Context(), upgrade) + + // define setupStreams to pass the connection, the stopchannel, and the success response + setupStreams := func() (io.Writer, io.Writer, chan os.Signal, func(), error) { + return conn, conn, stopChannel, func() { + fmt.Fprintf(conn, successResponse) + }, nil + } + + opts := &types.AttachOptions{ + GetStreams: setupStreams, + UseStdin: httputils.BoolValue(r, "stdin"), + UseStdout: httputils.BoolValue(r, "stdout"), + UseStderr: httputils.BoolValue(r, "stderr"), + Logs: httputils.BoolValue(r, "logs"), + Stream: httputils.BoolValue(r, "stream"), + // TODO: implement DetachKeys now that David's nerdctl detachkeys is implemented + //DetachKeys: r.URL.Query().Get("detachKeys"), + // TODO: note that MuxStreams should be used in both in checkUpgradeStatus as well as + // service.Attach, but since we always start containers in detached mode with tty=false, + // whether the stream and the output will be multiplexed will always be true + MuxStreams: true, + } + + err = h.service.Attach(r.Context(), mux.Vars(r)["id"], opts) + if err != nil { + statusCode := http.StatusInternalServerError + if errdefs.IsNotFound(err) { + statusCode = http.StatusNotFound + } + statusText := http.StatusText(statusCode) + fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\n"+ + "Content-Type: %s\r\n\r\n%s\r\n", statusCode, statusText, contentType, err.Error()) + } + if conn != nil { + httputils.CloseStreams(conn) + } +} + +// checkUpgradeStatus checks if the connection needs to be upgraded and returns the correct +// type and response +func checkUpgradeStatus(ctx context.Context, upgrade bool) (string, string) { + contentType := "application/vnd.docker.raw-stream" + successResponse := fmt.Sprintf("HTTP/1.1 200 OK\r\n" + + "Content-Type: application/vnd.docker.raw-stream\r\n\r\n") + if upgrade { + if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.42") { + contentType = "application/vnd.docker.multiplexed-stream" + } + successResponse = fmt.Sprintf("HTTP/1.1 101 UPGRADED\r\n" + + "Content-Type: " + contentType + "\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: tcp\r\n\r\n") + } + return contentType, successResponse +} + +// checkConnection monitors the hijacked connection and checks whether the connection is closed, +// running a closer function when it is closed. +// +// TODO: Refactor when we implement stdin +func checkConnection(conn net.Conn, closer func()) { + //logger.Debugf("Checking if connection is still available") + one := make([]byte, 1) + if _, err := conn.Read(one); err == io.EOF { + //logger.Debugf("Closing connection") + closer() + conn.Close() + conn = nil + return + } +} diff --git a/pkg/api/handlers/container/attach_test.go b/pkg/api/handlers/container/attach_test.go new file mode 100644 index 00000000..704c112d --- /dev/null +++ b/pkg/api/handlers/container/attach_test.go @@ -0,0 +1,303 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + + "github.com/gorilla/mux" + "github.com/moby/moby/api/server/httputils" + "github.com/runfinch/finch-daemon/pkg/api/types" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pkg/errors" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_http" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + + hj "github.com/getlantern/httptest" +) + +const errRRErrMsg = "error hijacking the connection" + +var _ = Describe("Container Attach API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *hj.HijackableResponseRecorder + req *http.Request + ) + BeforeEach(func() { + //initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = hj.NewRecorder(nil) + }) + Context("handler", func() { + It("should return an internal server error when failing to hijack the conn", func() { + // define expected values, setup mock, create request using ErrorResponseRecorder defined below + expErrCode := http.StatusInternalServerError + expErrMsg := errRRErrMsg + rrErr := newErrorResponseRecorder() + req, _ = http.NewRequest(http.MethodPost, "/containers/123", nil) + + h.attach(rrErr, req) + + Expect(rrErr.Code()).Should(Equal(expErrCode)) + Expect(rrErr.Body()).Should(MatchJSON(`{"message": "` + expErrMsg + `"}`)) + }) + It("should return an internal server error for default errors", func() { + // define expected values, setup mock, create request + expErrCode := http.StatusInternalServerError + expErrMsg := "error" + service.EXPECT().Attach(gomock.Any(), gomock.Any(), gomock.Any()). + Return(fmt.Errorf(expErrMsg)) + req, _ = http.NewRequest(http.MethodPost, "/containers/123", nil) + + h.attach(rr, req) + + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal(fmt.Sprintf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\n\r\n%s\r\n", expErrCode, + http.StatusText(expErrCode), "application/vnd.docker.raw-stream", expErrMsg))) + }) + It("should return a 404 error for container not found", func() { + // define expected values, setup mock, create request + expErrCode := http.StatusNotFound + expErrMsg := fmt.Sprintf("no container is found given the string: %s", "123") + service.EXPECT().Attach(gomock.Any(), gomock.Any(), gomock.Any()). + Return(errdefs.NewNotFound(fmt.Errorf(expErrMsg))) + req, _ = http.NewRequest(http.MethodPost, "/containers/123", nil) + + h.attach(rr, req) + + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal(fmt.Sprintf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\n\r\n%s\r\n", expErrCode, + http.StatusText(expErrCode), "application/vnd.docker.raw-stream", expErrMsg))) + }) + It("should succeed upon no errors in service.Attach and close the connection", func() { + service.EXPECT().Attach(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + req, _ = http.NewRequest(http.MethodPost, "/containers/123", nil) + + h.attach(rr, req) + + Expect(rr.Closed()).Should(BeTrue()) + }) + It("should handle the url variable parsing correctly", func() { + cid := "test_con" + vars := map[string]string{ + "id": cid, + } + expectedOpts := &types.AttachOptions{ + GetStreams: func() (io.Writer, io.Writer, chan os.Signal, func(), error) { + conn, _, err := rr.Hijack() + Expect(err).Should(BeNil()) + return conn, conn, nil, nil, nil + }, + UseStdin: true, + UseStdout: true, + UseStderr: true, + Logs: true, + Stream: true, + MuxStreams: true, + } + service.EXPECT().Attach(gomock.Any(), cid, attachOptsEqualTo(expectedOpts)).Return(nil) + req, _ = http.NewRequest(http.MethodPost, "/containers/"+cid+"/attach?"+ + "stdin=1&"+ + "stdout=1&"+ + "stderr=1&"+ + "logs=1&"+ + "stream=1", nil) + req = mux.SetURLVars(req, vars) + + h.attach(rr, req) + + Expect(rr.Closed()).Should(BeTrue()) + }) + }) + Context("testing the checkUpgradeStatus helper function", func() { + var ( + defHeader func(ct string) string + upgradedHeader func(ct string) string + defContentType string + muxContentType string + ) + BeforeEach(func() { + defHeader = func(ct string) string { + return "HTTP/1.1 200 OK\r\n" + + "Content-Type: " + ct + "\r\n\r\n" + } + upgradedHeader = func(ct string) string { + return "HTTP/1.1 101 UPGRADED\r\n" + + "Content-Type: " + ct + "\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: tcp\r\n\r\n" + } + defContentType = "application/vnd.docker.raw-stream" + muxContentType = "application/vnd.docker.multiplexed-stream" + }) + It("should not return an upgraded header if upgrade is false", func() { + ct, r := checkUpgradeStatus(context.Background(), false) + + Expect(r).Should(Equal(defHeader(defContentType))) + Expect(ct).Should(Equal(defContentType)) + }) + It("should return an upgraded header without mux if version < 1.42 & upgrade is true", func() { + ctx := context.WithValue(context.Background(), httputils.APIVersionKey{}, "1.41") + ct, r := checkUpgradeStatus(ctx, true) + + Expect(r).Should(Equal(upgradedHeader(defContentType))) + Expect(ct).Should(Equal(defContentType)) + }) + It("should return an upgraded header with mux if version = 1.42 & upgrade is true", func() { + ctx := context.WithValue(context.Background(), httputils.APIVersionKey{}, "1.42") + ct, r := checkUpgradeStatus(ctx, true) + + Expect(r).Should(Equal(upgradedHeader(muxContentType))) + Expect(ct).Should(Equal(muxContentType)) + }) + It("should return an upgraded header with mux if version > 1.42 & upgrade is true", func() { + ctx := context.WithValue(context.Background(), httputils.APIVersionKey{}, "1.43") + ct, r := checkUpgradeStatus(ctx, true) + + Expect(r).Should(Equal(upgradedHeader(muxContentType))) + Expect(ct).Should(Equal(muxContentType)) + }) + }) + Context("testing the checkConnection helper function", func() { + var ( + mockConn *mocks_http.MockConn + ) + BeforeEach(func() { + mockConn = mocks_http.NewMockConn(mockCtrl) + mockConn.EXPECT().Close().Do(func() { + mockConn = nil + }) + }) + It("should close the connection when there is an io.EOF error", func() { + mockConn.EXPECT().Read(gomock.Any()).Return(0, io.EOF) + checkConnection(mockConn, func() {}) + Expect(mockConn).Should(BeNil()) + }) + It("shouldn't close the connection when there are no errors", func() { + mockConn.EXPECT().Read(gomock.Any()).Return(0, nil) + checkConnection(mockConn, func() {}) + Expect(mockConn).ShouldNot(BeNil()) + }) + It("shouldn't close the connection when there is a non io.EOF error", func() { + mockConn.EXPECT().Read(gomock.Any()).Return(0, errors.New("not io.EOF")) + checkConnection(mockConn, func() {}) + Expect(mockConn).ShouldNot(BeNil()) + }) + }) + +}) + +func newErrorResponseRecorder() *errorResponseRecorder { + wrapped := httptest.NewRecorder() + h := &errorResponseRecorder{ + wrapped: wrapped, + } + return h +} + +type errorResponseRecorder struct { + wrapped *httptest.ResponseRecorder +} + +func (h *errorResponseRecorder) Header() http.Header { + return h.wrapped.Header() +} + +func (h *errorResponseRecorder) Write(bytes []byte) (int, error) { + return h.wrapped.Write(bytes) +} + +func (h *errorResponseRecorder) WriteHeader(statusCode int) { + h.wrapped.WriteHeader(statusCode) +} + +func (h *errorResponseRecorder) Body() *bytes.Buffer { + return h.wrapped.Body +} + +func (h *errorResponseRecorder) Code() int { + return h.wrapped.Code +} + +func (h *errorResponseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return nil, nil, fmt.Errorf(errRRErrMsg) +} + +// attachOptsMatcher is adapted from container create to be a wrapper type to +// compare attach option structs when we cannot define what GetStreams will be +type attachOptsMatcher struct { + obj *types.AttachOptions + mismatches []string +} + +func attachOptsEqualTo(object *types.AttachOptions) *attachOptsMatcher { + return &attachOptsMatcher{ + obj: object, + mismatches: []string{}, + } +} + +func (e *attachOptsMatcher) Matches(x interface{}) bool { + y := x.(*types.AttachOptions) + + gotStdout, gotStderr, _, _, gotErr := y.GetStreams() + wantStdout, wantStderr, _, _, wantErr := e.obj.GetStreams() + if wantStdout != gotStdout { + e.mismatches = append(e.mismatches, "GetStreams() - stdout") + } + if wantStderr != gotStderr { + e.mismatches = append(e.mismatches, "GetStreams() - stderr") + } + if gotErr != wantErr { + e.mismatches = append(e.mismatches, "GetStreams() - error") + } + if e.obj.UseStdin != y.UseStdin { + e.mismatches = append(e.mismatches, "UseStdin") + } + if e.obj.UseStdout != y.UseStdout { + e.mismatches = append(e.mismatches, "UseStdout") + } + if e.obj.UseStderr != y.UseStderr { + e.mismatches = append(e.mismatches, "UseStderr") + } + if e.obj.Logs != y.Logs { + e.mismatches = append(e.mismatches, "Logs") + } + if e.obj.Stream != y.Stream { + e.mismatches = append(e.mismatches, "Stream") + } + + if len(e.mismatches) > 0 { + return false + } + return true +} + +func (e *attachOptsMatcher) String() string { + return strings.Join(e.mismatches, ",") +} diff --git a/pkg/api/handlers/container/container.go b/pkg/api/handlers/container/container.go new file mode 100644 index 00000000..e6b100af --- /dev/null +++ b/pkg/api/handlers/container/container.go @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package container comprises functions and structures related to container APIs +package container + +import ( + "context" + "io" + "net/http" + "time" + + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/config" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +//go:generate mockgen --destination=../../../mocks/mocks_container/containersvc.go -package=mocks_container github.com/runfinch/finch-daemon/pkg/api/handlers/container Service +type Service interface { + GetPathToFilesInContainer(ctx context.Context, cid string, path string) (string, func(), error) + Remove(ctx context.Context, cid string, force, removeVolumes bool) error + Wait(ctx context.Context, cid string, condition string) (code int64, err error) + Start(ctx context.Context, cid string) error + Stop(ctx context.Context, cid string, timeout *time.Duration) error + Create(ctx context.Context, image string, cmd []string, createOpt ncTypes.ContainerCreateOptions, netOpt ncTypes.NetworkOptions) (string, error) + Inspect(ctx context.Context, cid string) (*types.Container, error) + WriteFilesAsTarArchive(filePath string, writer io.Writer, slashDot bool) error + Attach(ctx context.Context, cid string, opts *types.AttachOptions) error + List(ctx context.Context, listOpts ncTypes.ContainerListOptions) ([]types.ContainerListItem, error) + Rename(ctx context.Context, cid string, newName string, opts ncTypes.ContainerRenameOptions) error + Logs(ctx context.Context, cid string, opts *types.LogsOptions) error + ExtractArchiveInContainer(ctx context.Context, putArchiveOpt *types.PutArchiveOptions, body io.ReadCloser) error + Stats(ctx context.Context, cid string) (<-chan *types.StatsJSON, error) + ExecCreate(ctx context.Context, cid string, config types.ExecConfig) (string, error) +} + +// RegisterHandlers register all the supported endpoints related to the container APIs +func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Config, logger flog.Logger) { + h := newHandler(service, conf, logger) + + r.SetPrefix("/containers") + r.HandleFunc("/{id:.*}", h.remove, http.MethodDelete) + r.HandleFunc("/{id:.*}/start", h.start, http.MethodPost) + r.HandleFunc("/{id:.*}/stop", h.stop, http.MethodPost) + r.HandleFunc("/{id:.*}/remove", h.remove, http.MethodPost) + r.HandleFunc("/{id:.*}/wait", h.wait, http.MethodPost) + r.HandleFunc("/create", h.create, http.MethodPost) + r.HandleFunc("/{id:.*}/json", h.inspect, http.MethodGet) + r.HandleFunc("/{id:.*}/archive", h.getArchive, http.MethodGet) + r.HandleFunc("/{id:.*}/attach", h.attach, http.MethodPost) + r.HandleFunc("/json", h.list, http.MethodGet) + r.HandleFunc("/{id:.*}/rename", h.rename, http.MethodPost) + r.HandleFunc("/{id:.*}/logs", h.logs, http.MethodGet) + r.HandleFunc("/{id:.*}/archive", h.putArchive, http.MethodPut) + r.HandleFunc("/{id:.*}/stats", h.stats, http.MethodGet) + r.HandleFunc("/{id:.*}/exec", h.exec, http.MethodPost) +} + +// newHandler creates the handler that serves all the container related APIs +func newHandler(service Service, conf *config.Config, logger flog.Logger) *handler { + return &handler{ + service: service, + Config: conf, + logger: logger, + } +} + +type handler struct { + service Service + Config *config.Config + logger flog.Logger +} diff --git a/pkg/api/handlers/container/container_test.go b/pkg/api/handlers/container/container_test.go new file mode 100644 index 00000000..b1f59ab9 --- /dev/null +++ b/pkg/api/handlers/container/container_test.go @@ -0,0 +1,141 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// TestContainerHandler function is the entry point of container handler package's unit test using ginkgo +func TestContainerHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Container APIs Handler") +} + +// Unit tests related to check RegisterHandlers() has configured the endpoint properly for containers related API +var _ = Describe("Container API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + rr *httptest.ResponseRecorder + req *http.Request + conf config.Config + router *mux.Router + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + router = mux.NewRouter() + RegisterHandlers(types.VersionedRouter{Router: router}, service, &conf, logger) + rr = httptest.NewRecorder() + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + }) + Context("handlers", func() { + It("should call container delete method", func() { + // setup mocks + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("error from delete api")) + req, _ = http.NewRequest(http.MethodDelete, "/containers/123", nil) + // call the API to check if it returns the error generated from the remove method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from delete api"}`)) + }) + It("should call container start method", func() { + //setup mocks + service.EXPECT().Start(gomock.Any(), gomock.Any()).Return(fmt.Errorf("error from start api")) + req, _ = http.NewRequest(http.MethodPost, "/containers/123/start", nil) + // call the API to check if it returns the error generated from start method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from start api"}`)) + }) + It("should call container stop method", func() { + // setup mocks + service.EXPECT().Stop(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("error from stop api")) + req, _ = http.NewRequest(http.MethodPost, "/containers/123/stop", nil) + // call the API to check if it returns the error generated from stop method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from stop api"}`)) + }) + It("should call container create method", func() { + //setup mocks + body := []byte(`{"Image": "test-image"}`) + service.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", fmt.Errorf("error from create api")) + req, _ = http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + // call the API to check if it returns the error generated from create method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from create api"}`)) + }) + It("should call container attach method", func() { + req, _ = http.NewRequest(http.MethodPost, "/containers/123/attach", nil) + + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr).Should(HaveHTTPBody(`{"message":"the response writer is not a http.Hijacker"}` + "\n")) + }) + It("should call container inspect method", func() { + //setup mocks + service.EXPECT().Inspect(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error from inspect api")) + req, _ = http.NewRequest(http.MethodGet, "/containers/123/json", nil) + // call the API to check if it returns the error generated from inspect method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from inspect api"}`)) + }) + It("should call container list method", func() { + //setup mocks + service.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error from list api")) + req, _ = http.NewRequest(http.MethodGet, "/containers/json", nil) + // call the API to check if it returns the error generated from list method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from list api"}`)) + }) + It("should call container rename method", func() { + //setup mocks + service.EXPECT().Rename(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("error from rename api")) + req, _ = http.NewRequest(http.MethodPost, "/containers/123/rename", nil) + // call the API to check if it returns the error generated from list method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from rename api"}`)) + }) + It("should call container logs method", func() { + req, _ = http.NewRequest(http.MethodGet, "/containers/123/logs", nil) + + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message":"you must choose at least one stream"}`)) + }) + It("should call container stats method", func() { + //setup mocks + service.EXPECT().Stats(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error from stats api")) + req, _ = http.NewRequest(http.MethodGet, "/containers/123/stats", nil) + // call the API to check if it returns the error generated from stats method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from stats api"}`)) + }) + }) + +}) diff --git a/pkg/api/handlers/container/create.go b/pkg/api/handlers/container/create.go new file mode 100644 index 00000000..255758d3 --- /dev/null +++ b/pkg/api/handlers/container/create.go @@ -0,0 +1,248 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/containerd/containerd/namespaces" + gocni "github.com/containerd/go-cni" + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/defaults" + "github.com/docker/go-connections/nat" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +type containerCreateResponse struct { + ID string `json:"Id"` +} + +func (h *handler) create(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + platform := r.URL.Query().Get("platform") + + var req types.ContainerCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + + // AttachStdin is currently not supported + // TODO: Remove this check when attach supports stdin + if req.AttachStdin { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("AttachStdin is currently not supported during create")) + return + } + + // defaults + stopSignal := "SIGTERM" // nerdctl default. + if req.StopSignal != "" { + stopSignal = req.StopSignal + } + stopTimeout := 10 // Docker API default. + if req.StopTimeout != nil { + stopTimeout = *req.StopTimeout + } + memory := "" + if req.HostConfig.Memory > 0 { + memory = fmt.Sprint(req.HostConfig.Memory) + } + + // Volumes: + // nerdctl expects volumes to be a list of bind mounts or individual user created volumes. + // Each element is formatted as "HOST_PATH:CONTAINER_PATH:BIND_OPTIONS". Example: "/tmp/workdir:/workdir:ro". + // Or simply "VOLUME", where VOLUME is a user created volume. + volumes := req.HostConfig.Binds + if req.Volumes != nil { + for newVolume, _ := range req.Volumes { + // If a volume points to one of the directories already mapped to a host path in bind mounts, it should not be added as a separate volume. + contained := false + for _, volume := range volumes { + bindOpts := strings.Split(volume, ":") + if len(bindOpts) > 1 && newVolume == bindOpts[1] || newVolume == volume { + contained = true + break + } + } + if !contained { + volumes = append(volumes, newVolume) + } + } + } + + // Labels: + // labels are passed in as a map of strings, + // but nerdctl expects an array of strings with format [LABEL1=VALUE1, LABEL2=VALUE2, ...]. + labels := []string{} + if req.Labels != nil { + for key, val := range req.Labels { + labels = append(labels, fmt.Sprintf("%s=%s", key, val)) + } + } + + // Environment vars: + env := []string{} + if req.Env != nil { + env = append(env, req.Env...) + } + + // Linux Capabilities + capAdd := []string{} + if req.HostConfig.CapAdd != nil { + capAdd = req.HostConfig.CapAdd + } + + globalOpt := ncTypes.GlobalCommandOptions(*h.Config) + createOpt := ncTypes.ContainerCreateOptions{ + Stdout: nil, + Stderr: nil, + GOptions: globalOpt, + + // #region for basic flags + Interactive: false, // TODO: update this after attach supports STDIN + TTY: false, // TODO: update this after attach supports STDIN + Detach: true, // TODO: current implementation of create does not support AttachStdin, AttachStdout, and AttachStderr flags + Restart: "no", // Docker API default. + Rm: req.HostConfig.AutoRemove, // Automatically remove container upon exit + Pull: "missing", // nerdctl default. + StopSignal: stopSignal, + StopTimeout: stopTimeout, + // #endregion + + // #region for platform flags + Platform: platform, // target platform + // #endregion + + // #region for isolation flags + Isolation: "default", // nerdctl default. + // #endregion + + // #region for resource flags + Memory: memory, // memory limit (in bytes) + CPUQuota: -1, // nerdctl default. + MemorySwappiness64: -1, // nerdctl default. + PidsLimit: -1, // nerdctl default. + Cgroupns: defaults.CgroupnsMode(), // nerdctl default. + // #endregion + + // #region for user flags + User: req.User, + GroupAdd: []string{}, // nerdctl default. + // #endregion + + // #region for security flags + SecurityOpt: []string{}, // nerdctl default. + CapAdd: capAdd, + CapDrop: []string{}, // nerdctl default. + // #endregion + + // #region for runtime flags + Runtime: defaults.Runtime, // nerdctl default. + // #endregion + + // #region for volume flags + Volume: volumes, + // #endregion + + // #region for env flags + Env: env, + Workdir: req.WorkingDir, + Entrypoint: req.Entrypoint, + EntrypointChanged: req.Entrypoint != nil && len(req.Entrypoint) > 0, + // #endregion + + // #region for metadata flags + Name: name, // container name + Label: labels, // container labels + // #endregion + + // #region for logging flags + LogDriver: "json-file", // nerdctl default. + // #endregion + + // #region for image pull and verify options + ImagePullOpt: ncTypes.ImagePullOptions{ + GOptions: globalOpt, + VerifyOptions: ncTypes.ImageVerifyOptions{Provider: "none"}, + IPFSAddress: "", + Stdout: nil, + Stderr: nil, + }, + // #endregion + } + + portMappings, err := translatePortMappings(req.HostConfig.PortBindings) + if err != nil { + logrus.Debugf("failed to parse port mappings: %s", err) + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + networkMode := req.HostConfig.NetworkMode + if networkMode == "" || networkMode == "default" { + networkMode = "bridge" + } + netOpt := ncTypes.NetworkOptions{ + Hostname: req.Hostname, + NetworkSlice: []string{networkMode}, // TODO: Set to none if "NetworkDisabled" is true in request + DNSResolvConfOptions: []string{}, // nerdctl default. + PortMappings: portMappings, + } + + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + cid, err := h.service.Create(ctx, req.Image, req.Cmd, createOpt, netOpt) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsInvalidFormat(err): + code = http.StatusBadRequest + case errdefs.IsConflict(err): + code = http.StatusConflict + default: + code = http.StatusInternalServerError + } + logrus.Debugf("Create Container API failed. Status code %d, Message: %s", code, err) + response.SendErrorResponse(w, code, err) + return + } + response.JSON(w, http.StatusCreated, containerCreateResponse{cid}) +} + +// translate docker port mappings to go-cni port mappings +func translatePortMappings(portMappings nat.PortMap) ([]gocni.PortMapping, error) { + ports := []gocni.PortMapping{} + if portMappings == nil { + return ports, nil + } + for portName, portBindings := range portMappings { + for _, portBinding := range portBindings { + hostPort, err := strconv.ParseInt(portBinding.HostPort, 10, 32) + if err != nil { + return []gocni.PortMapping{}, fmt.Errorf("failed to parse host port (%s) to integer: %w", portBinding.HostPort, err) + } + // Cannot use portName.Int() because it assumes nat.NewPort() was used + // for error handling. + containerPort, err := strconv.ParseInt(portName.Port(), 10, 32) + if err != nil { + return []gocni.PortMapping{}, fmt.Errorf("failed to parse container port (%s) to integer: %w", portName, err) + } + portMap := gocni.PortMapping{ + HostPort: int32(hostPort), + ContainerPort: int32(containerPort), + Protocol: portName.Proto(), + HostIP: portBinding.HostIP, + } + ports = append(ports, portMap) + } + } + return ports, nil +} diff --git a/pkg/api/handlers/container/create_test.go b/pkg/api/handlers/container/create_test.go new file mode 100644 index 00000000..2d1a3e50 --- /dev/null +++ b/pkg/api/handlers/container/create_test.go @@ -0,0 +1,576 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + + gocni "github.com/containerd/go-cni" + "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/defaults" + "github.com/docker/go-connections/nat" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Create API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + createOpt types.ContainerCreateOptions + netOpt types.NetworkOptions + cid string + jsonResponse interface{} + h *handler + rr *httptest.ResponseRecorder + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + createOpt = getDefaultCreateOpt(c) + netOpt = getDefaultNetOpt() + cid = "123" + jsonResponse = `{"Id": "123"}` + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + }) + Context("handler", func() { + It("should return 201 as success response", func() { + body := []byte(`{"Image": "test-image"}`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + // service mock returns container id and nil error upon success. + service.EXPECT().Create(gomock.Any(), "test-image", gomock.Nil(), equalTo(createOpt), equalTo(netOpt)).Return( + cid, nil) + + // handler should return success message with 201 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(jsonResponse)) + }) + + It("should set the Cmd argument", func() { + body := []byte(`{ + "Image": "test-image", + "Cmd": ["echo", "hello world"] + }`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + service.EXPECT().Create(gomock.Any(), "test-image", []string{"echo", "hello world"}, equalTo(createOpt), equalTo(netOpt)).Return( + cid, nil) + + // handler should return success message with 201 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(jsonResponse)) + }) + + It("should handle valid port mappings", func() { + body := []byte(`{ + "Image": "test-image", + "ExposedPorts": {"8000/tcp": {}, "9000/udp": {}}, + "HostConfig": { + "PortBindings": { + "8000/tcp": [{"HostIp": "", "HostPort": "8001"}], + "9000/udp": [{"HostIp": "127.0.0.1", "HostPort": "9001"}] + } + } + }`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + // define expected go-cni port mappings and network settings + portMaps := []gocni.PortMapping{ + gocni.PortMapping{ + HostPort: 8001, + ContainerPort: 8000, + Protocol: "tcp", + HostIP: "", + }, + gocni.PortMapping{ + HostPort: 9001, + ContainerPort: 9000, + Protocol: "udp", + HostIP: "127.0.0.1", + }, + } + + // port mappings can be in any order + netOpt1 := types.NetworkOptions{ + Hostname: netOpt.Hostname, + NetworkSlice: netOpt.NetworkSlice, + DNSResolvConfOptions: netOpt.DNSResolvConfOptions, + PortMappings: portMaps, + } + netOpt2 := types.NetworkOptions{ + Hostname: netOpt.Hostname, + NetworkSlice: netOpt.NetworkSlice, + DNSResolvConfOptions: netOpt.DNSResolvConfOptions, + PortMappings: []gocni.PortMapping{portMaps[1], portMaps[0]}, + } + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), anyOf(netOpt1, netOpt2)).Return( + cid, nil) + + // handler should return success message with 201 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(jsonResponse)) + }) + + It("should return 400 for invalid port mappings", func() { + body := []byte(`{ + "Image": "test-image", + "ExposedPorts": {"8000/tcp": {}}, + "HostConfig": { + "PortBindings": { + "8000/tcp": [{"HostIp": "", "HostPort": ""}], + } + } + }`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + // handler should return bad request message with 400 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + + It("should set the default network mode to bridge", func() { + body := []byte(`{ + "Image": "test-image", + "HostConfig": { + "NetworkMode": "default" + } + }`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + cid, nil) + + // handler should return success message with 201 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(jsonResponse)) + }) + + It("should set the specified network mode", func() { + body := []byte(`{ + "Image": "test-image", + "HostConfig": { + "NetworkMode": "net1" + } + }`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + // define expected network mode + netOpt.NetworkSlice = []string{"net1"} + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + cid, nil) + + // handler should return success message with 201 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(jsonResponse)) + }) + + It("should set container name and platform parameters", func() { + body := []byte(`{"Image": "test-image"}`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create?name=test-cont&platform=arm64", bytes.NewReader(body)) + + // expected name and platform parameters + createOpt.Name = "test-cont" + createOpt.Platform = "arm64" + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + cid, nil) + + // handler should return success message with 201 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(jsonResponse)) + }) + + It("should set specified create options", func() { + body := []byte(`{ + "Image": "test-image", + "HostConfig": { + "AutoRemove": true, + "Memory": 209715200 + }, + "User": "test-user", + "Env": ["VARIABLE1=1", "VAR2=var2"], + "WorkingDir": "/test-dir", + "Entrypoint": ["echo", "hello"], + "StopSignal": "SIGINT", + "StopTimeout": 500 + }`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + // expected create options + createOpt.Rm = true + createOpt.User = "test-user" + createOpt.Env = []string{"VARIABLE1=1", "VAR2=var2"} + createOpt.Workdir = "/test-dir" + createOpt.Entrypoint = []string{"echo", "hello"} + createOpt.EntrypointChanged = true + createOpt.StopSignal = "SIGINT" + createOpt.StopTimeout = 500 + createOpt.Memory = "209715200" + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + cid, nil) + + // handler should return success message with 201 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(jsonResponse)) + }) + + It("should set specified volume mounts", func() { + body := []byte(`{ + "Image": "test-image", + "HostConfig": { + "Binds": ["/tmp/workdir:/workdir:ro,delegated", "test-vol1:/mnt/test-vol1", "test-vol2"] + }, + "Volumes": { + "test-vol3": {}, + "/workdir": {} + } + }`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + // expected volume options + createOpt.Volume = []string{ + "/tmp/workdir:/workdir:ro,delegated", + "test-vol1:/mnt/test-vol1", + "test-vol2", + "test-vol3", + } + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + cid, nil) + + // handler should return success message with 201 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(jsonResponse)) + }) + + It("should return 404 if the image was not found", func() { + body := []byte(`{"Image": "test-image"}`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + "", errdefs.NewNotFound(errors.New("error message"))) + + // handler should return error message with 404 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + + It("should return 400 if the inputs are invalid", func() { + body := []byte(`{"Image": "test-image"}`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + "", errdefs.NewInvalidFormat(errors.New("error message"))) + + // handler should return error message with 400 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + + It("should return 409 if the container already exists", func() { + body := []byte(`{"Image": "test-image"}`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + "", errdefs.NewConflict(errors.New("error message"))) + + // handler should return error message with 409 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusConflict)) + }) + + It("should return 500 for internal failures", func() { + body := []byte(`{"Image": "test-image"}`) + req, _ := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + + service.EXPECT().Create(gomock.Any(), "test-image", nil, equalTo(createOpt), equalTo(netOpt)).Return( + "", errors.New("error message")) + + // handler should return error message with 500 status code. + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + + It("should return 400 Bad Request for container attach stdin during create", func() { + body := []byte(`{"AttachStdin": true}`) + req, err := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + Expect(err).ShouldNot(HaveOccurred()) + + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body.String()).Should(ContainSubstring("not supported")) + }) + + It("should return 400 Bad Request for invalid port mappings during create", func() { + body := []byte(`{"HostConfig": {"PortBindings": {"22/tcp": [{"HostPort": "Twenty-Two"}]}}}`) + req, err := http.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + Expect(err).ShouldNot(HaveOccurred()) + + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body.String()).Should(ContainSubstring("failed to parse")) + }) + + Context("translate port mappings", func() { + It("should return empty if port mappings is nil", func() { + Expect(translatePortMappings(nil)).Should(BeEmpty()) + }) + + It("should return an error if port map binding is invalid", func() { + portMappings := nat.PortMap{ + "80/tcp": { + nat.PortBinding{ + HostIP: "127.0.0.1", + HostPort: "invalid-port-number", + }, + }, + } + cniPortMappings, err := translatePortMappings(portMappings) + Expect(err).Should(HaveOccurred()) + Expect(cniPortMappings).Should(BeEmpty()) + }) + + It("should return an error if container port is invalid", func() { + portMappings := nat.PortMap{ + "invalid-port-number/tcp": { + nat.PortBinding{ + HostIP: "127.0.0.1", + HostPort: "300", + }, + }, + } + cniPortMappings, err := translatePortMappings(portMappings) + Expect(err).Should(HaveOccurred()) + Expect(cniPortMappings).Should(BeEmpty()) + }) + + It("should return the expected port mappings", func() { + expected := []gocni.PortMapping{ + { + HostPort: 300, + ContainerPort: 80, + Protocol: "tcp", + HostIP: "127.0.0.1", + }, + { + HostPort: 42, + ContainerPort: 8080, + Protocol: "tcp", + HostIP: "127.0.0.1", + }, + } + portMappings := nat.PortMap{ + "80/tcp": { + nat.PortBinding{ + HostIP: "127.0.0.1", + HostPort: "300", + }, + }, + "8080/tcp": { + nat.PortBinding{ + HostIP: "127.0.0.1", + HostPort: "42", + }, + }, + } + cniPortMappings, err := translatePortMappings(portMappings) + Expect(err).ShouldNot(HaveOccurred()) + Expect(cniPortMappings).ShouldNot(BeEmpty()) + Expect(cniPortMappings).Should(ContainElements(expected)) + }) + }) + }) +}) + +// define default container create options +func getDefaultCreateOpt(conf config.Config) types.ContainerCreateOptions { + globalOpt := types.GlobalCommandOptions{ + Debug: conf.Debug, + DebugFull: conf.DebugFull, + Address: conf.Address, + Namespace: conf.Namespace, + Snapshotter: conf.Snapshotter, + CNIPath: conf.CNIPath, + CNINetConfPath: conf.CNINetConfPath, + DataRoot: conf.DataRoot, + CgroupManager: conf.CgroupManager, + InsecureRegistry: conf.InsecureRegistry, + HostsDir: conf.HostsDir, + Experimental: conf.Experimental, + HostGatewayIP: conf.HostGatewayIP, + } + return types.ContainerCreateOptions{ + Stdout: nil, + Stderr: nil, + GOptions: globalOpt, + + // #region for basic flags + Interactive: false, // TODO: update this after attach supports STDIN + TTY: false, // TODO: update this after attach supports STDIN + Detach: true, // TODO: current implementation of create does not support AttachStdin, AttachStdout, and AttachStderr flags + Restart: "no", // Docker API default. + Rm: false, // Automatically remove container upon exit + Pull: "missing", // nerdctl default. + StopSignal: "SIGTERM", + StopTimeout: 10, + // #endregion + + // #region for platform flags + Platform: "", // target platform + // #endregion + + // #region for isolation flags + Isolation: "default", // nerdctl default. + // #endregion + + // #region for resource flags + CPUQuota: -1, // nerdctl default. + MemorySwappiness64: -1, // nerdctl default. + PidsLimit: -1, // nerdctl default. + Cgroupns: defaults.CgroupnsMode(), // nerdctl default. + // #endregion + + // #region for user flags + User: "", + GroupAdd: []string{}, // nerdctl default. + // #endregion + + // #region for security flags + SecurityOpt: []string{}, // nerdctl default. + CapAdd: []string{}, // nerdctl default. + CapDrop: []string{}, // nerdctl default. + // #endregion + + // #region for runtime flags + Runtime: defaults.Runtime, // nerdctl default. + // #endregion + + // #region for volume flags + Volume: nil, + // #endregion + + // #region for env flags + Env: []string{}, + Workdir: "", + Entrypoint: nil, + EntrypointChanged: false, + // #endregion + + // #region for metadata flags + Name: "", // container name + Label: []string{}, // container labels + // #endregion + + // #region for logging flags + LogDriver: "json-file", // nerdctl default. + // #endregion + + // #region for image pull and verify types + ImagePullOpt: types.ImagePullOptions{ + GOptions: globalOpt, + VerifyOptions: types.ImageVerifyOptions{Provider: "none"}, + IPFSAddress: "", + Stdout: nil, + Stderr: nil, + }, + // #endregion + } +} + +// define default network types +func getDefaultNetOpt() types.NetworkOptions { + return types.NetworkOptions{ + Hostname: "", + NetworkSlice: []string{"bridge"}, // nerdctl default. + DNSResolvConfOptions: []string{}, // nerdctl default. + PortMappings: []gocni.PortMapping{}, + } +} + +// anyOfMatcher is a gomock matcher that returns true if the object is contained in an array slice +type anyOfMatcher struct { + slice []interface{} +} + +func anyOf(elements ...interface{}) *anyOfMatcher { + return &anyOfMatcher{elements} +} + +func (a *anyOfMatcher) Matches(x interface{}) bool { + for _, element := range a.slice { + if reflect.DeepEqual(element, x) { + return true + } + } + return false +} + +func (a *anyOfMatcher) String() string { + return fmt.Sprintf("one of the elements in slice: %v", a.slice) +} + +// equalToMatcher is a gomock matcher similar to gomock.Eq(), but it prints specific fields upon mismatch. +// This is useful for comparing large structs. +type equalToMatcher struct { + obj interface{} + mismatches []string +} + +func equalTo(object interface{}) *equalToMatcher { + return &equalToMatcher{ + obj: object, + mismatches: []string{}, + } +} + +func (e *equalToMatcher) Matches(x interface{}) bool { + e.mismatches = []string{} + v1 := reflect.ValueOf(e.obj) + v2 := reflect.ValueOf(x) + t := reflect.TypeOf(e.obj) + for i := 0; i < v1.NumField(); i++ { + f1 := v1.Field(i).Interface() + f2 := v2.Field(i).Interface() + if !reflect.DeepEqual(f1, f2) { + e.mismatches = append(e.mismatches, + fmt.Sprintf("{%s: Got: %#v, Want: %#v}", t.Field(i).Name, f2, f1)) + } + } + if len(e.mismatches) > 0 { + return false + } + return true +} + +func (e *equalToMatcher) String() string { + return strings.Join(e.mismatches, ",") +} diff --git a/pkg/api/handlers/container/exec.go b/pkg/api/handlers/container/exec.go new file mode 100644 index 00000000..0092192c --- /dev/null +++ b/pkg/api/handlers/container/exec.go @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// exec creates a new exec instance +func (h *handler) exec(w http.ResponseWriter, r *http.Request) { + cid, ok := mux.Vars(r)["id"] + if !ok { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("must specify a container id")) + return + } + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + + if r.Body == nil { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("request body should not be empty")) + return + } + + config := &types.ExecConfig{} + if err := json.NewDecoder(r.Body).Decode(config); err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(fmt.Errorf("unable to parse request body: %w", err))) + return + } + + eid, err := h.service.ExecCreate(ctx, cid, *config) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsConflict(err): + code = http.StatusConflict + default: + code = http.StatusInternalServerError + } + response.JSON(w, code, response.NewError(err)) + return + } + + response.JSON(w, http.StatusCreated, &types.ExecCreateResponse{ + Id: eid, + }) + return +} diff --git a/pkg/api/handlers/container/exec_test.go b/pkg/api/handlers/container/exec_test.go new file mode 100644 index 00000000..18c7430c --- /dev/null +++ b/pkg/api/handlers/container/exec_test.go @@ -0,0 +1,112 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Exec API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + execConfig *types.ExecConfig + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + execConfig = &types.ExecConfig{ + User: "foo", + Privileged: false, + Tty: true, + ConsoleSize: &[2]uint{123, 123}, + AttachStdin: false, + AttachStderr: true, + AttachStdout: true, + Detach: false, + DetachKeys: "ctrl-C", + Env: []string{"FOO=bar"}, + WorkingDir: "/foo/bar", + Cmd: []string{"foo", "bar"}, + } + bodyBytes, err := json.Marshal(execConfig) + Expect(err).Should(BeNil()) + req, err = http.NewRequest(http.MethodPost, "/containers/123/exec", bytes.NewReader(bodyBytes)) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123"}) + }) + Context("handler", func() { + It("should return 201 on successful exec", func() { + service.EXPECT().ExecCreate(gomock.Any(), "123", *execConfig).Return("exec123", nil) + + h.exec(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(rr.Body).Should(MatchJSON(`{"Id": "exec123"}`)) + }) + It("should return 404 if the container is not found", func() { + service.EXPECT().ExecCreate(gomock.Any(), "123", *execConfig).Return("", errdefs.NewNotFound(fmt.Errorf("not found"))) + + h.exec(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "not found"}`)) + }) + It("should return 409 if the container is not running", func() { + service.EXPECT().ExecCreate(gomock.Any(), "123", *execConfig).Return("", errdefs.NewConflict(fmt.Errorf("not running"))) + + h.exec(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusConflict)) + Expect(rr.Body).Should(MatchJSON(`{"message": "not running"}`)) + }) + It("should return 500 on any other error", func() { + service.EXPECT().ExecCreate(gomock.Any(), "123", *execConfig).Return("", fmt.Errorf("exec create error")) + + h.exec(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "exec create error"}`)) + }) + It("should return 400 if the request body is not an ExecConfig", func() { + var err error + req, err = http.NewRequest(http.MethodPost, "/containers/123/exec", bytes.NewReader([]byte("foo"))) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123"}) + + h.exec(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "unable to parse request body: invalid character 'o' in literal false (expecting 'a')"}`)) + }) + It("should return 400 if the request body is empty", func() { + var err error + req, err = http.NewRequest(http.MethodPost, "/containers/123/exec", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123"}) + + h.exec(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "request body should not be empty"}`)) + }) + }) +}) diff --git a/pkg/api/handlers/container/get_archive.go b/pkg/api/handlers/container/get_archive.go new file mode 100644 index 00000000..ecc64e0c --- /dev/null +++ b/pkg/api/handlers/container/get_archive.go @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + "os" + "strings" + + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) getArchive(w http.ResponseWriter, r *http.Request) { + cid := mux.Vars(r)["id"] + path := r.URL.Query().Get("path") + if path == "" { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("must specify a file or directory path")) + return + } + + filePath, cleanup, err := h.service.GetPathToFilesInContainer(r.Context(), cid, path) + if cleanup != nil { + defer cleanup() + } + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + default: + code = http.StatusInternalServerError + } + h.logger.Debugf("Responding with error. Error code: %d, Message: %s", code, err.Error()) + response.SendErrorResponse(w, code, err) + return + } + + // "/." is a Docker thing that instructions the copy command to download contents of the folder only + pathHasSlashDot := strings.HasSuffix(path, string(os.PathSeparator)+".") + + w.Header().Set("Content-Type", "application/x-tar") + // TODO: to be compatible with the docker CLI, we will need to implement the X-Docker-Container-Path-Stat header here + // see https://github.com/docker/go-docker/blob/4daae26030ad00e348edddff9767924ae57a3b82/container_copy.go#L90 for + // where the CLI uses it + w.WriteHeader(http.StatusOK) + // path.Join() removes "/." from the end of a path, so filePath will never end in "/.". therefore, we need to propagate + // a bool that tells us whether the original path had a "/." + err = h.service.WriteFilesAsTarArchive(filePath, w, pathHasSlashDot) + if err != nil { + h.logger.Errorf("Could not send response: %s\n", err) + } +} diff --git a/pkg/api/handlers/container/get_archive_test.go b/pkg/api/handlers/container/get_archive_test.go new file mode 100644 index 00000000..730ba2ea --- /dev/null +++ b/pkg/api/handlers/container/get_archive_test.go @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Get Archive API", func() { + var ( + mockCtrl *gomock.Controller + service *mocks_container.MockService + conf *config.Config + logger *mocks_logger.Logger + mockPath string + r *mux.Router + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + service = mocks_container.NewMockService(mockCtrl) + conf = &config.Config{} + logger = mocks_logger.NewLogger(mockCtrl) + mockPath = "./mockPath" + r = mux.NewRouter() + RegisterHandlers(types.VersionedRouter{Router: r}, service, conf, logger) + rr = httptest.NewRecorder() + }) + Context("handler", func() { + It("should return 200 as success response", func() { + req, _ = http.NewRequest(http.MethodGet, "/containers/123/archive?path=%2Fhome", nil) + service.EXPECT().GetPathToFilesInContainer(gomock.Any(), "123", "/home").Return(mockPath, nil, nil) + service.EXPECT().WriteFilesAsTarArchive(mockPath, gomock.Any(), false).Return(nil) + + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusOK)) + }) + It("should return 400 if the path is not specified", func() { + req, _ = http.NewRequest(http.MethodGet, "/containers/123/archive", nil) + + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "must specify a file or directory path"}`)) + }) + It("should return 404 if CopyFilesFromContainer returns a NotFound error", func() { + req, _ = http.NewRequest(http.MethodGet, "/containers/123/archive?path=%2Fhome", nil) + service.EXPECT().GetPathToFilesInContainer(gomock.Any(), "123", "/home").Return("", nil, errdefs.NewNotFound(fmt.Errorf("not found"))) + logger.EXPECT().Debugf("Responding with error. Error code: %d, Message: %s", http.StatusNotFound, "not found") + + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "not found"}`)) + }) + It("should return 500 if CopyFilesFromContainer returns any other error", func() { + req, _ = http.NewRequest(http.MethodGet, "/containers/123/archive?path=%2Fhome", nil) + service.EXPECT().GetPathToFilesInContainer(gomock.Any(), "123", "/home").Return("", nil, fmt.Errorf("internal error")) + + logger.EXPECT().Debugf("Responding with error. Error code: %d, Message: %s", http.StatusInternalServerError, "internal error") + + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "internal error"}`)) + }) + It("should run a cleanup function returned from GetPathToFilesInContainer", func() { + cleanupHasRun := false + cleanup := func() { + cleanupHasRun = true + } + req, _ = http.NewRequest(http.MethodGet, "/containers/123/archive?path=%2Fhome", nil) + service.EXPECT().GetPathToFilesInContainer(gomock.Any(), "123", "/home").Return(mockPath, cleanup, nil) + service.EXPECT().WriteFilesAsTarArchive(mockPath, gomock.Any(), false).Return(nil) + + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusOK)) + Expect(cleanupHasRun).Should(BeTrue()) + }) + }) +}) diff --git a/pkg/api/handlers/container/inspect.go b/pkg/api/handlers/container/inspect.go new file mode 100644 index 00000000..a2234b6e --- /dev/null +++ b/pkg/api/handlers/container/inspect.go @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +func (h *handler) inspect(w http.ResponseWriter, r *http.Request) { + cid := mux.Vars(r)["id"] + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + c, err := h.service.Inspect(ctx, cid) + // map the error into http status code and send response. + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + default: + code = http.StatusInternalServerError + } + logrus.Debugf("Inspect Container API failed. Status code %d, Message: %s", code, err) + response.SendErrorResponse(w, code, err) + + return + } + + // return JSON response + response.JSON(w, http.StatusOK, c) +} diff --git a/pkg/api/handlers/container/inspect_test.go b/pkg/api/handlers/container/inspect_test.go new file mode 100644 index 00000000..2c7abe69 --- /dev/null +++ b/pkg/api/handlers/container/inspect_test.go @@ -0,0 +1,86 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Inspect API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *httptest.ResponseRecorder + cid string + req *http.Request + resp types.Container + respJSON []byte + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + cid = "123" + var err error + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/%s/json", cid), nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123"}) + resp = types.Container{ + ID: cid, + Image: "test-image", + Name: "/test-container", + } + respJSON, err = json.Marshal(resp) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return inspect object and 200 status code upon success", func() { + service.EXPECT().Inspect(gomock.Any(), cid).Return(&resp, nil) + + // handler should return response object with 200 status code + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(respJSON)) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 404 status code if container was not found", func() { + service.EXPECT().Inspect(gomock.Any(), cid).Return(nil, errdefs.NewNotFound(fmt.Errorf("no such container"))) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // handler should return error message with 404 status code + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "no such container"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + It("should return 500 status code if service returns an error message", func() { + service.EXPECT().Inspect(gomock.Any(), cid).Return(nil, fmt.Errorf("error")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // handler should return error message + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + }) + +}) diff --git a/pkg/api/handlers/container/list.go b/pkg/api/handlers/container/list.go new file mode 100644 index 00000000..7ef6d0e9 --- /dev/null +++ b/pkg/api/handlers/container/list.go @@ -0,0 +1,117 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/containerd/containerd/namespaces" + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +const ( + allKey = "all" + limitKey = "limit" + sizeKey = "size" + filtersKey = "filters" + defaultAll = false + defaultSize = false +) + +func (h *handler) list(w http.ResponseWriter, r *http.Request) { + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + + q := r.URL.Query() + all, err := parseBoolQP(q, allKey, defaultAll) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("invalid query parameter \"all\": %s", err))) + return + } + limit, err := parseIntQP(q, limitKey, 0) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("invalid query parameter \"limit\": %s", err))) + return + } + // TODO: Size is not in the response so the parameter is not actually used. Add size to response later. + size, err := parseBoolQP(q, sizeKey, defaultSize) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("invalid query parameter \"size\": %s", err))) + return + } + filters, err := parseFiltersQP(q) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("invalid query parameter \"filters\": %s", err))) + return + } + + globalOpt := ncTypes.GlobalCommandOptions(*h.Config) + + listOpts := ncTypes.ContainerListOptions{ + GOptions: globalOpt, + All: all, + LastN: limit, + Truncate: true, + Size: size, + Filters: nerdctlFiltersFromAPIFilters(filters), + } + containers, err := h.service.List(ctx, listOpts) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + response.JSON(w, http.StatusOK, containers) +} + +func nerdctlFiltersFromAPIFilters(filters map[string][]string) []string { + var ncFilters []string + for filterType, filterList := range filters { + for _, f := range filterList { + ncFilters = append(ncFilters, fmt.Sprintf("%s=%s", filterType, f)) + } + } + return ncFilters +} + +func parseBoolQP(q url.Values, key string, defaultV bool) (bool, error) { + v := q.Get(key) + if v == "" { + return defaultV, nil + } else { + r, err := strconv.ParseBool(v) + if err != nil { + return false, err + } + return r, nil + } +} + +func parseIntQP(q url.Values, key string, defaultV int) (int, error) { + v := q.Get(key) + if v == "" { + return defaultV, nil + } else { + r, err := strconv.ParseInt(v, 10, 0) + if err != nil { + return 0, err + } + return int(r), nil + } +} + +func parseFiltersQP(q url.Values) (map[string][]string, error) { + filters := make(map[string][]string) + filterQuery := q.Get(filtersKey) + if filterQuery != "" { + err := json.Unmarshal([]byte(filterQuery), &filters) + if err != nil { + return nil, err + } + } + return filters, nil +} diff --git a/pkg/api/handlers/container/list_test.go b/pkg/api/handlers/container/list_test.go new file mode 100644 index 00000000..0b72a418 --- /dev/null +++ b/pkg/api/handlers/container/list_test.go @@ -0,0 +1,149 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container List API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *httptest.ResponseRecorder + globalOpts ncTypes.GlobalCommandOptions + resp []types.ContainerListItem + respJSON []byte + err error + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + globalOpts = ncTypes.GlobalCommandOptions(c) + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + resp = []types.ContainerListItem{ + { + Id: "id1", + Names: []string{"/name1"}, + }, + { + Id: "id2", + Names: []string{"/name2"}, + }, + } + respJSON, err = json.Marshal(resp) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return containers and 200 status code upon success with all query parameters", func() { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/json?all=true&limit=1&size=true&filters={\"status\": [\"paused\"]}"), nil) + Expect(err).Should(BeNil()) + listOpts := ncTypes.ContainerListOptions{ + GOptions: globalOpts, + All: true, + LastN: 1, + Truncate: true, + Size: true, + Filters: []string{"status=paused"}, + } + service.EXPECT().List(gomock.Any(), listOpts).Return(resp, nil) + + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(respJSON)) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return containers and 200 status code upon success with no query parameter", func() { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/json"), nil) + Expect(err).Should(BeNil()) + listOpts := ncTypes.ContainerListOptions{ + GOptions: globalOpts, + All: false, + LastN: 0, + Truncate: true, + Size: false, + Filters: nil, + } + service.EXPECT().List(gomock.Any(), listOpts).Return(resp, nil) + + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(respJSON)) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + + }) + It("should return 400 status code when there is error parsing all", func() { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/json?all=invalid"), nil) + Expect(err).Should(BeNil()) + errorMsg := fmt.Sprintf("invalid query parameter \\\"all\\\": %s", fmt.Errorf("strconv.ParseBool: parsing \\\"invalid\\\": invalid syntax")) + + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + It("should return 400 status code when there is error parsing limit", func() { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/json?limit=invalid"), nil) + Expect(err).Should(BeNil()) + errorMsg := fmt.Sprintf("invalid query parameter \\\"limit\\\": %s", fmt.Errorf("strconv.ParseInt: parsing \\\"invalid\\\": invalid syntax")) + + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + It("should return 400 status code when there is error parsing size", func() { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/json?size=invalid"), nil) + Expect(err).Should(BeNil()) + errorMsg := fmt.Sprintf("invalid query parameter \\\"size\\\": %s", fmt.Errorf("strconv.ParseBool: parsing \\\"invalid\\\": invalid syntax")) + + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + It("should return 400 status code when there is error parsing filters", func() { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/json?filters=invalid"), nil) + Expect(err).Should(BeNil()) + errorMsg := fmt.Sprintf("invalid query parameter \\\"filters\\\": %s", fmt.Errorf("invalid character 'i' looking for beginning of value")) + + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + It("should return 500 status code when service returns error", func() { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/json"), nil) + Expect(err).Should(BeNil()) + listOpts := ncTypes.ContainerListOptions{ + GOptions: globalOpts, + All: false, + LastN: 0, + Truncate: true, + Size: false, + Filters: nil, + } + errorMsg := "error from ListContainers" + service.EXPECT().List(gomock.Any(), listOpts).Return(nil, fmt.Errorf(errorMsg)) + + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "` + errorMsg + `"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + }) + +}) diff --git a/pkg/api/handlers/container/logs.go b/pkg/api/handlers/container/logs.go new file mode 100644 index 00000000..4a1a7137 --- /dev/null +++ b/pkg/api/handlers/container/logs.go @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "fmt" + "io" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/gorilla/mux" + "github.com/moby/moby/api/server/httputils" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// logs handles the http request for attaching to a container's logs +func (h *handler) logs(w http.ResponseWriter, r *http.Request) { + // return early if neither stdout and stderr are set + stdout, stderr := httputils.BoolValue(r, "stdout"), httputils.BoolValue(r, "stderr") + if !(stdout || stderr) { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg( + "you must choose at least one stream")) + return + } + + // setup hijacker && hijack connection + hijacker, ok := w.(http.Hijacker) + if !ok { + response.JSON(w, http.StatusBadRequest, response. + NewErrorFromMsg("the response writer is not a http.Hijacker")) + return + } + conn, _, err := hijacker.Hijack() + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + + // set raw mode + _, err = conn.Write([]byte{}) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + + // setup stop channel to communicate with logviewer, + stopChannel := make(chan os.Signal, 1) + signal.Notify(stopChannel, syscall.SIGTERM, syscall.SIGINT) + go checkConnection(conn, func() { + stopChannel <- os.Interrupt + }) + + contentType, successResponse := checkUpgradeStatus(r.Context(), false) + + // define setupStreams to pass the connection, the stopchannel, and the success response + setupStreams := func() (io.Writer, io.Writer, chan os.Signal, func(), error) { + return conn, conn, stopChannel, func() { + fmt.Fprintf(conn, successResponse) + }, nil + } + + opts := &types.LogsOptions{ + GetStreams: setupStreams, + Stdout: stdout, + Stderr: stderr, + Follow: httputils.BoolValueOrDefault(r, "follow", false), + Since: httputils.Int64ValueOrZero(r, "since"), + Until: httputils.Int64ValueOrZero(r, "until"), + Timestamps: httputils.BoolValueOrDefault(r, "timestamps", false), + Tail: r.Form.Get("tail"), + MuxStreams: true, + } + + err = h.service.Logs(r.Context(), mux.Vars(r)["id"], opts) + if err != nil { + statusCode := http.StatusInternalServerError + if errdefs.IsNotFound(err) { + statusCode = http.StatusNotFound + } + statusText := http.StatusText(statusCode) + fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\n"+ + "Content-Type: %s\r\n\r\n%s\r\n", statusCode, statusText, contentType, err.Error()) + } + if conn != nil { + httputils.CloseStreams(conn) + } +} diff --git a/pkg/api/handlers/container/logs_test.go b/pkg/api/handlers/container/logs_test.go new file mode 100644 index 00000000..43aa20b7 --- /dev/null +++ b/pkg/api/handlers/container/logs_test.go @@ -0,0 +1,200 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + + hj "github.com/getlantern/httptest" +) + +var _ = Describe("Container Logs API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *hj.HijackableResponseRecorder + req *http.Request + ) + BeforeEach(func() { + //initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + req, _ = http.NewRequest(http.MethodGet, "/containers/123/logs?stdout=1&stderr=1", nil) + h = newHandler(service, &c, logger) + rr = hj.NewRecorder(nil) + }) + Context("handler", func() { + It("should return an error if the user does not set stdout or stderr", func() { + expErrCode := http.StatusBadRequest + expErrMsg := "you must choose at least one stream" + req, _ = http.NewRequest(http.MethodGet, "/containers/123/logs", nil) + + h.logs(rr, req) + + Expect(rr.Code()).Should(Equal(expErrCode)) + Expect(rr.Body()).Should(MatchJSON(`{"message": "` + expErrMsg + `"}`)) + }) + It("should return an internal server error when failing to hijack the conn", func() { + // define expected values, setup mock, create request using ErrorResponseRecorder defined below + expErrCode := http.StatusInternalServerError + expErrMsg := errRRErrMsg + rrErr := newErrorResponseRecorder() + + h.logs(rrErr, req) + + Expect(rrErr.Code()).Should(Equal(expErrCode)) + Expect(rrErr.Body()).Should(MatchJSON(`{"message": "` + expErrMsg + `"}`)) + }) + It("should return an internal server error for default errors", func() { + // define expected values, setup mock, create request + expErrCode := http.StatusInternalServerError + expErrMsg := "error" + service.EXPECT().Logs(gomock.Any(), gomock.Any(), gomock.Any()). + Return(fmt.Errorf(expErrMsg)) + + h.logs(rr, req) + + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal(fmt.Sprintf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\n\r\n%s\r\n", expErrCode, + http.StatusText(expErrCode), "application/vnd.docker.raw-stream", expErrMsg))) + }) + It("should return a 404 error for container not found", func() { + // define expected values, setup mock, create request + expErrCode := http.StatusNotFound + expErrMsg := fmt.Sprintf("no container is found given the string: %s", "123") + service.EXPECT().Logs(gomock.Any(), gomock.Any(), gomock.Any()). + Return(errdefs.NewNotFound(fmt.Errorf(expErrMsg))) + + h.logs(rr, req) + + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal(fmt.Sprintf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\n\r\n%s\r\n", expErrCode, + http.StatusText(expErrCode), "application/vnd.docker.raw-stream", expErrMsg))) + }) + It("should succeed upon no errors in service.Attach and close the connection", func() { + service.EXPECT().Logs(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + h.logs(rr, req) + + Expect(rr.Closed()).Should(BeTrue()) + }) + It("should handle the url variable parsing correctly", func() { + cid := "test_con" + vars := map[string]string{ + "id": cid, + } + expectedOpts := &types.LogsOptions{ + GetStreams: func() (io.Writer, io.Writer, chan os.Signal, func(), error) { + conn, _, err := rr.Hijack() + Expect(err).Should(BeNil()) + return conn, conn, nil, nil, nil + }, + Stdout: true, + Stderr: true, + Follow: true, + Since: 10, + Until: 11, + Timestamps: true, + Tail: "all", + MuxStreams: true, + } + service.EXPECT().Logs(gomock.Any(), cid, logsOptsEqualTo(expectedOpts)).Return(nil) + req, _ = http.NewRequest(http.MethodGet, "/containers/"+cid+"/logs?"+ + "stdout=1&"+ + "stderr=1&"+ + "follow=1&"+ + "since=10&"+ + "until=11&"+ + "timestamps=1&"+ + "tail=all", nil) + req = mux.SetURLVars(req, vars) + + h.logs(rr, req) + + Expect(rr.Closed()).Should(BeTrue()) + }) + }) +}) + +// logsOptsMatcher is adapted from container create to be a wrapper type to +// compare logs option structs when we cannot define what GetStreams will be +type logsOptsMatcher struct { + obj *types.LogsOptions + mismatches []string +} + +func logsOptsEqualTo(object *types.LogsOptions) *logsOptsMatcher { + return &logsOptsMatcher{ + obj: object, + mismatches: []string{}, + } +} + +func (e *logsOptsMatcher) Matches(x interface{}) bool { + y := x.(*types.LogsOptions) + + gotStdout, gotStderr, _, _, gotErr := y.GetStreams() + wantStdout, wantStderr, _, _, wantErr := e.obj.GetStreams() + if wantStdout != gotStdout { + e.mismatches = append(e.mismatches, "GetStreams() - stdout") + } + if wantStderr != gotStderr { + e.mismatches = append(e.mismatches, "GetStreams() - stderr") + } + if gotErr != wantErr { + e.mismatches = append(e.mismatches, "GetStreams() - error") + } + if e.obj.Stdout != y.Stdout { + e.mismatches = append(e.mismatches, "Stdout") + } + if e.obj.Stderr != y.Stderr { + e.mismatches = append(e.mismatches, "Stderr") + } + if e.obj.Follow != y.Follow { + e.mismatches = append(e.mismatches, "Follow") + } + if e.obj.Since != y.Since { + e.mismatches = append(e.mismatches, "Since") + } + if e.obj.Until != y.Until { + e.mismatches = append(e.mismatches, "Until") + } + if e.obj.Timestamps != y.Timestamps { + e.mismatches = append(e.mismatches, "Timestamps") + } + if e.obj.Tail != y.Tail { + e.mismatches = append(e.mismatches, "Tail") + } + if e.obj.MuxStreams != y.MuxStreams { + e.mismatches = append(e.mismatches, "MuxStreams") + } + + if len(e.mismatches) > 0 { + return false + } + return true +} + +func (e *logsOptsMatcher) String() string { + return strings.Join(e.mismatches, ",") +} diff --git a/pkg/api/handlers/container/put_archive.go b/pkg/api/handlers/container/put_archive.go new file mode 100644 index 00000000..3b83f0ed --- /dev/null +++ b/pkg/api/handlers/container/put_archive.go @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/moby/moby/api/server/httputils" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) putArchive(w http.ResponseWriter, r *http.Request) { + cid := mux.Vars(r)["id"] + path := r.URL.Query().Get("path") + if path == "" { + h.logger.Error("error handling request, bad path") + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("must specify a file or directory path")) + return + } + + opts := &types.PutArchiveOptions{ + ContainerId: cid, + Path: path, + Overwrite: httputils.BoolValue(r, "noOverwriteDirNonDir"), + CopyUIDGID: httputils.BoolValue(r, "copyUIDGID"), + } + err := h.service.ExtractArchiveInContainer(r.Context(), opts, r.Body) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsForbiddenError(err): + code = http.StatusForbidden + case errdefs.IsInvalidFormat(err): + code = http.StatusBadRequest + default: + code = http.StatusInternalServerError + } + h.logger.Errorf("error handling request %v", err) + response.SendErrorResponse(w, code, err) + return + } + w.WriteHeader(http.StatusOK) + return +} diff --git a/pkg/api/handlers/container/put_archive_test.go b/pkg/api/handlers/container/put_archive_test.go new file mode 100644 index 00000000..00201964 --- /dev/null +++ b/pkg/api/handlers/container/put_archive_test.go @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Put Archive API", func() { + var ( + mockCtrl *gomock.Controller + service *mocks_container.MockService + conf *config.Config + logger *mocks_logger.Logger + r *mux.Router + rr *httptest.ResponseRecorder + req *http.Request + putArchiveOpts *types.PutArchiveOptions + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + service = mocks_container.NewMockService(mockCtrl) + conf = &config.Config{} + logger = mocks_logger.NewLogger(mockCtrl) + r = mux.NewRouter() + RegisterHandlers(types.VersionedRouter{Router: r}, service, conf, logger) + rr = httptest.NewRecorder() + putArchiveOpts = &types.PutArchiveOptions{ + ContainerId: "123", + Path: "/home", + } + }) + Context("handler", func() { + It("should return 200 as success response", func() { + req, _ = http.NewRequest(http.MethodPut, "/containers/123/archive?path=%2Fhome", nil) + putArchiveOpts.ContainerId = "123" + putArchiveOpts.Path = "/home" + service.EXPECT().ExtractArchiveInContainer(gomock.Any(), putArchiveOpts, gomock.Any()).Return(nil) + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusOK)) + }) + It("should return 403 as response on forbidden error from service", func() { + req, _ = http.NewRequest(http.MethodPut, "/containers/123/archive?path=%2Fhome", nil) + e := errdefs.NewForbidden(errors.New("forbidden")) + service.EXPECT().ExtractArchiveInContainer(gomock.Any(), putArchiveOpts, gomock.Any()).Return(e) + logger.EXPECT().Errorf("error handling request %v", e) + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusForbidden)) + }) + It("should return 404 as response on not found error from service", func() { + req, _ = http.NewRequest(http.MethodPut, "/containers/123/archive?path=%2Fhome", nil) + e := errdefs.NewNotFound(errors.New("not found")) + service.EXPECT().ExtractArchiveInContainer(gomock.Any(), putArchiveOpts, gomock.Any()).Return(e) + logger.EXPECT().Errorf("error handling request %v", e) + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusNotFound)) + }) + It("should return 400 as response on invalid format error from service", func() { + req, _ = http.NewRequest(http.MethodPut, "/containers/123/archive?path=%2Fhome", nil) + e := errdefs.NewInvalidFormat(errors.New("invalid format")) + service.EXPECT().ExtractArchiveInContainer(gomock.Any(), putArchiveOpts, gomock.Any()).Return(e) + logger.EXPECT().Errorf("error handling request %v", e) + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusBadRequest)) + }) + It("should return 400 as response on empty path", func() { + req, _ = http.NewRequest(http.MethodPut, "/containers/123/archive?path=", nil) + logger.EXPECT().Error("error handling request, bad path") + r.ServeHTTP(rr, req) + Expect(rr.Code).Should(Equal(http.StatusBadRequest)) + }) + }) +}) diff --git a/pkg/api/handlers/container/remove.go b/pkg/api/handlers/container/remove.go new file mode 100644 index 00000000..a7ee27ce --- /dev/null +++ b/pkg/api/handlers/container/remove.go @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + "strconv" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +func (h *handler) remove(w http.ResponseWriter, r *http.Request) { + cid := mux.Vars(r)["id"] + volumeFlag, err := strconv.ParseBool(r.URL.Query().Get("v")) + if err != nil { + volumeFlag = false + } + forceFlag, err := strconv.ParseBool(r.URL.Query().Get("force")) + if err != nil { + forceFlag = false + } + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + err = h.service.Remove(ctx, cid, forceFlag, volumeFlag) + // map the error into http status code and send response. + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsConflict(err): + code = http.StatusConflict + default: + code = http.StatusInternalServerError + } + logrus.Debugf("Remove container API responding with error code. Status code %d, Message: %s", code, err.Error()) + response.SendErrorResponse(w, code, err) + + return + } + // successfully deleted. Send no content status. + response.Status(w, http.StatusNoContent) +} diff --git a/pkg/api/handlers/container/remove_test.go b/pkg/api/handlers/container/remove_test.go new file mode 100644 index 00000000..e144ea2c --- /dev/null +++ b/pkg/api/handlers/container/remove_test.go @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Remove API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, "/containers/123", nil) + }) + Context("handler", func() { + It("should return 204 as success response", func() { + // service mock returns nil to mimic handler removed the container successfully. + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + //handler should return success message with 204 status code. + h.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent)) + }) + + It("should return 404 not found response", func() { + // service mock returns not found error to mimic user trying to delete container that does not exist + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + errdefs.NewNotFound(fmt.Errorf("container not found"))) + + //handler should return 404 status code with an error msg. + h.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "container not found"}`)) + }) + It("should return 500 internal error response", func() { + // service mock return error to mimic a user trying to delete a container with an id that has + // multiple containers with same prefix. + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + fmt.Errorf("multiple IDs found with provided prefix")) + + //handler should return 500 status code with an error msg. + h.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "multiple IDs found with provided prefix"}`)) + }) + It("should return 409 conflict error when container is running", func() { + // service mock returns not found error to mimic user trying to delete container that is running + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + errdefs.NewConflict(fmt.Errorf("container is in running"))) + + //handler should return 409 status code with an error msg. + h.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusConflict)) + Expect(rr.Body).Should(MatchJSON(`{"message": "container is in running"}`)) + }) + }) + +}) diff --git a/pkg/api/handlers/container/rename.go b/pkg/api/handlers/container/rename.go new file mode 100644 index 00000000..cca9592a --- /dev/null +++ b/pkg/api/handlers/container/rename.go @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + + "github.com/containerd/containerd/namespaces" + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +func (h *handler) rename(w http.ResponseWriter, r *http.Request) { + cid := mux.Vars(r)["id"] + newName := r.URL.Query().Get("name") + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + globalOpt := ncTypes.GlobalCommandOptions(*h.Config) + opts := ncTypes.ContainerRenameOptions{ + GOptions: globalOpt, + Stdout: nil, + } + err := h.service.Rename(ctx, cid, newName, opts) + // map the error into http status code and send response. + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsConflict(err): + code = http.StatusConflict + default: + code = http.StatusInternalServerError + } + logrus.Debugf("Rename container API responding with error code. Status code %d, Message: %v", code, err) + response.SendErrorResponse(w, code, err) + return + } + // successfully stopped. Send no content status. + response.Status(w, http.StatusNoContent) +} diff --git a/pkg/api/handlers/container/rename_test.go b/pkg/api/handlers/container/rename_test.go new file mode 100644 index 00000000..184bb5af --- /dev/null +++ b/pkg/api/handlers/container/rename_test.go @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Rename API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, "/containers/123/rename", nil) + req = mux.SetURLVars(req, map[string]string{"id": "123"}) + + }) + Context("handler", func() { + It("should return 204 as success response", func() { + // service mock returns nil to mimic handler stopped the container successfully. + service.EXPECT().Rename(gomock.Any(), "123", gomock.Any(), gomock.Any()).Return(nil) + + //handler should return success message with 204 status code. + h.rename(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent)) + }) + + It("should return 404 not found response", func() { + // service mock returns not found error to mimic user trying to stop container that does not exist + service.EXPECT().Rename(gomock.Any(), "123", gomock.Any(), gomock.Any()).Return( + errdefs.NewNotFound(fmt.Errorf("container not found"))) + + //handler should return 404 status code with an error msg. + h.rename(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "container not found"}`)) + }) + It("should return 500 internal error response", func() { + // service mock return error to mimic a user trying to stop a container with an id that has + // multiple containers with same prefix. + service.EXPECT().Rename(gomock.Any(), "123", gomock.Any(), gomock.Any()).Return( + fmt.Errorf("multiple IDs found with provided prefix")) + + //handler should return 500 status code with an error msg. + h.rename(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "multiple IDs found with provided prefix"}`)) + }) + It("should return 409 conflict error when container cannot be renamed", func() { + // service mock returns not found error to mimic user trying to stop container that is running + service.EXPECT().Rename(gomock.Any(), "123", gomock.Any(), gomock.Any()).Return( + errdefs.NewConflict(fmt.Errorf("container cannot be renamed"))) + + //handler should return 409 status code with an error msg. + h.rename(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusConflict)) + }) + }) +}) diff --git a/pkg/api/handlers/container/start.go b/pkg/api/handlers/container/start.go new file mode 100644 index 00000000..3e3b9e05 --- /dev/null +++ b/pkg/api/handlers/container/start.go @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +func (h *handler) start(w http.ResponseWriter, r *http.Request) { + cid := mux.Vars(r)["id"] + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + err := h.service.Start(ctx, cid) + // map the error into http status code and send response. + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsNotModified(err): + code = http.StatusNotModified + default: + code = http.StatusInternalServerError + } + logrus.Debugf("Start container API responding with error code. Status code %d, Message: %s", code, err.Error()) + response.SendErrorResponse(w, code, err) + return + } + // successfully started the container. Send no content status. + response.Status(w, http.StatusNoContent) +} diff --git a/pkg/api/handlers/container/start_test.go b/pkg/api/handlers/container/start_test.go new file mode 100644 index 00000000..fca3a759 --- /dev/null +++ b/pkg/api/handlers/container/start_test.go @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Start API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, "/containers/123/start", nil) + req = mux.SetURLVars(req, map[string]string{"id": "123"}) + + }) + Context("handler", func() { + It("should return 204 as success response", func() { + // service mock returns nil to mimic handler started the container successfully. + service.EXPECT().Start(gomock.Any(), "123").Return(nil) + + //handler should return success message with 204 status code. + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent)) + }) + + It("should return 404 not found response", func() { + // service mock returns not found error to mimic user trying to start container that does not exist + service.EXPECT().Start(gomock.Any(), "123").Return( + errdefs.NewNotFound(fmt.Errorf("container not found"))) + + //handler should return 404 status code with an error msg. + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "container not found"}`)) + }) + It("should return 500 internal error response", func() { + // service mock return error to mimic a user trying to start a container with an id that has + // multiple containers with same prefix. + service.EXPECT().Start(gomock.Any(), "123").Return( + fmt.Errorf("multiple IDs found with provided prefix")) + + //handler should return 500 status code with an error msg. + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "multiple IDs found with provided prefix"}`)) + }) + It("should return 304 not-modified error when container is already running", func() { + // service mock returns not found error to mimic user trying to start container that is running + service.EXPECT().Start(gomock.Any(), "123").Return( + errdefs.NewNotModified(fmt.Errorf("container already running"))) + + //handler should return 304 status code with an error msg. + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotModified)) + }) + }) +}) diff --git a/pkg/api/handlers/container/stats.go b/pkg/api/handlers/container/stats.go new file mode 100644 index 00000000..d8aa8d48 --- /dev/null +++ b/pkg/api/handlers/container/stats.go @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) stats(w http.ResponseWriter, r *http.Request) { + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + + stream, err := strconv.ParseBool(r.URL.Query().Get("stream")) + if err != nil { + stream = true // stream is true by default + } + + cid := mux.Vars(r)["id"] + statsCh, err := h.service.Stats(ctx, cid) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + default: + code = http.StatusInternalServerError + } + h.logger.Debugf("Stats container API responding with error code. Status code %d, Message: %s", code, err) + response.SendErrorResponse(w, code, err) + return + } + + // set http header and initialize json encoder and response writer + f, ok := w.(http.Flusher) + if !ok { + response.SendErrorResponse( + w, + http.StatusInternalServerError, + fmt.Errorf("http ResponseWriter is not a http Flusher"), + ) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + e := json.NewEncoder(w) + + // get the first set of stats object + statsJSON, ok := <-statsCh + if !ok { + h.logger.Errorf("stats channel closed unexpectedly") + return + } + err = e.Encode(*statsJSON) + if err != nil { + h.logger.Errorf("error encoding stats to json: %s", err) + return + } + f.Flush() + + // if streaming is disabled, simply return + if !stream { + return + } + + // continuously send stats updates as JSON objects + for statsJSON := range statsCh { + err = e.Encode(*statsJSON) + if err != nil { + h.logger.Errorf("error encoding stats to json: %s", err) + return + } + f.Flush() + } +} diff --git a/pkg/api/handlers/container/stats_test.go b/pkg/api/handlers/container/stats_test.go new file mode 100644 index 00000000..ebd11c10 --- /dev/null +++ b/pkg/api/handlers/container/stats_test.go @@ -0,0 +1,163 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "time" + + "github.com/containerd/nerdctl/pkg/config" + dockertypes "github.com/docker/docker/api/types" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Stats API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + cid string + statsData types.StatsJSON + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + cid = "123" + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/%s/stats", cid), nil) + req = mux.SetURLVars(req, map[string]string{"id": cid}) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + // create a dummy stats object + statsData = types.StatsJSON{ + ID: cid, + Name: "test-container", + } + statsData.PidsStats = dockertypes.PidsStats{Current: 10, Limit: 20} + statsData.CPUStats = types.CPUStats{ + CPUUsage: dockertypes.CPUUsage{ + TotalUsage: 1000, + UsageInKernelmode: 500, + UsageInUsermode: 250, + PercpuUsage: []uint64{1, 2, 3, 4}, + }, + SystemUsage: 2500, + OnlineCPUs: 3, + } + statsData.MemoryStats = dockertypes.MemoryStats{ + Usage: 250, + Limit: 1000, + MaxUsage: 500, + Failcnt: 50, + } + }) + Context("handler", func() { + It("should return 404 if container was not found", func() { + service.EXPECT().Stats(gomock.Any(), cid).Return( + nil, errdefs.NewNotFound(fmt.Errorf("no such container"))) + + // handler should return 404 status code with an error msg. + h.stats(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "no such container"}`)) + }) + It("should fail with 500 status code for service error messages", func() { + service.EXPECT().Stats(gomock.Any(), cid).Return( + nil, fmt.Errorf("internal error")) + + // handler should return 500 status code with an error msg. + h.stats(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "internal error"}`)) + }) + It("should show stats once and exit when streaming is disabled", func() { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/containers/%s/stats?stream=false", cid), nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": cid}) + statsCh := make(chan *types.StatsJSON, 20) + service.EXPECT().Stats(gomock.Any(), cid).Return( + statsCh, nil) + + // populate stats channel with 10 stats objects + statsCh <- &statsData + for i := 2; i <= 10; i++ { + st := types.StatsJSON{} + st.Read = time.Now() + statsCh <- &st + } + + // handler should return 200 status code with a single JSON stats object. + h.stats(rr, req) + expectedJSON, err := json.Marshal(statsData) + Expect(err).Should(BeNil()) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr.Body).Should(MatchJSON(expectedJSON)) + }) + It("should log an error and exit gracefully when stats cannot be received from the channel", func() { + statsCh := make(chan *types.StatsJSON, 10) + service.EXPECT().Stats(gomock.Any(), cid).Return( + statsCh, nil) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + + // close the channel so there is nothing to read + close(statsCh) + + // handler should return 200 status code with an empty body. + h.stats(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr.Body.String()).Should(BeEmpty()) + }) + It("should stream stats", func() { + statsCh := make(chan *types.StatsJSON, 20) + service.EXPECT().Stats(gomock.Any(), cid).Return( + statsCh, nil) + + // setup a goroutine to populate stats channel with 10 objects + allStats := []*types.StatsJSON{} + go func() { + defer close(statsCh) + for i := 1; i <= 10; i++ { + st := types.StatsJSON{ + ID: cid, + Name: statsData.Name, + } + st.PidsStats = statsData.PidsStats + st.CPUStats = statsData.CPUStats + st.Read = time.Now() + statsCh <- &st + allStats = append(allStats, &st) + time.Sleep(time.Millisecond * 100) + } + }() + + // handler should return 200 status code with 10 JSON stats objects. + h.stats(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + for _, st := range allStats { + stJSON, err := json.Marshal(*st) + Expect(err).Should(BeNil()) + body, err := rr.Body.ReadBytes('\n') + Expect(err).Should(BeNil()) + Expect(body).Should(MatchJSON(stJSON)) + } + }) + }) +}) diff --git a/pkg/api/handlers/container/stop.go b/pkg/api/handlers/container/stop.go new file mode 100644 index 00000000..ae681e94 --- /dev/null +++ b/pkg/api/handlers/container/stop.go @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + "strconv" + "time" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +func (h *handler) stop(w http.ResponseWriter, r *http.Request) { + cid := mux.Vars(r)["id"] + t, err := strconv.ParseInt(r.URL.Query().Get("t"), 10, 64) + if err != nil { + t = 10 // Docker/nerdctl default + } + timeout := time.Second * time.Duration(t) + + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + err = h.service.Stop(ctx, cid, &timeout) + // map the error into http status code and send response. + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsNotModified(err): + code = http.StatusNotModified + default: + code = http.StatusInternalServerError + } + logrus.Debugf("Stop container API responding with error code. Status code %d, Message: %v", code, err) + response.SendErrorResponse(w, code, err) + return + } + // successfully stopped. Send no content status. + response.Status(w, http.StatusNoContent) +} diff --git a/pkg/api/handlers/container/stop_test.go b/pkg/api/handlers/container/stop_test.go new file mode 100644 index 00000000..c4f9a18b --- /dev/null +++ b/pkg/api/handlers/container/stop_test.go @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Stop API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_container.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_container.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, "/containers/123/stop", nil) + req = mux.SetURLVars(req, map[string]string{"id": "123"}) + + }) + Context("handler", func() { + It("should return 204 as success response", func() { + // service mock returns nil to mimic handler stopped the container successfully. + service.EXPECT().Stop(gomock.Any(), "123", gomock.Any()).Return(nil) + + //handler should return success message with 204 status code. + h.stop(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent)) + }) + + It("should return 404 not found response", func() { + // service mock returns not found error to mimic user trying to stop container that does not exist + service.EXPECT().Stop(gomock.Any(), "123", gomock.Any()).Return( + errdefs.NewNotFound(fmt.Errorf("container not found"))) + + //handler should return 404 status code with an error msg. + h.stop(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "container not found"}`)) + }) + It("should return 500 internal error response", func() { + // service mock return error to mimic a user trying to stop a container with an id that has + // multiple containers with same prefix. + service.EXPECT().Stop(gomock.Any(), "123", gomock.Any()).Return( + fmt.Errorf("multiple IDs found with provided prefix")) + + //handler should return 500 status code with an error msg. + h.stop(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "multiple IDs found with provided prefix"}`)) + }) + It("should return 304 not-modified error when container is already stopped", func() { + // service mock returns not found error to mimic user trying to stop container that is running + service.EXPECT().Stop(gomock.Any(), "123", gomock.Any()).Return( + errdefs.NewNotModified(fmt.Errorf("container already stopped"))) + + //handler should return 409 status code with an error msg. + h.stop(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotModified)) + }) + }) +}) diff --git a/pkg/api/handlers/container/wait.go b/pkg/api/handlers/container/wait.go new file mode 100644 index 00000000..ef1a6a83 --- /dev/null +++ b/pkg/api/handlers/container/wait.go @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +type waitError struct { + Message string +} + +type waitResp struct { + StatusCode int64 + Error *waitError `json:",omitempty"` +} + +func (h *handler) wait(w http.ResponseWriter, r *http.Request) { + cid := mux.Vars(r)["id"] + + // TODO: condition is not used because nerdctl doesn't support it + condition := r.URL.Query().Get("condition") + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + code, err := h.service.Wait(ctx, cid, condition) + + if code == -1 { + var errorCode int + switch { + case errdefs.IsNotFound(err): + errorCode = http.StatusNotFound + case errdefs.IsInvalidFormat(err): + errorCode = http.StatusBadRequest + default: + errorCode = http.StatusInternalServerError + } + logrus.Debugf("Wait container API responding with error code. Status code %d, Message: %s", errorCode, err.Error()) + response.SendErrorResponse(w, errorCode, err) + + return + } + + waitResponse := waitResp{ + StatusCode: code, + } + // if there is no err then don't need to set the error msg. e.g. when container is stopped the wait should return + // {"Error":null,"StatusCode":0} + if err != nil { + waitResponse.Error = &waitError{Message: err.Error()} + } + + response.JSON(w, http.StatusOK, waitResponse) +} diff --git a/pkg/api/handlers/exec/exec.go b/pkg/api/handlers/exec/exec.go new file mode 100644 index 00000000..3c5aef33 --- /dev/null +++ b/pkg/api/handlers/exec/exec.go @@ -0,0 +1,55 @@ +package exec + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/containerd/nerdctl/pkg/config" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +//go:generate mockgen --destination=../../../mocks/mocks_exec/execsvc.go -package=mocks_exec github.com/runfinch/finch-daemon/pkg/api/handlers/exec Service +type Service interface { + Start(ctx context.Context, options *types.ExecStartOptions) error + Resize(ctx context.Context, options *types.ExecResizeOptions) error + Inspect(ctx context.Context, conId string, execId string) (*types.ExecInspect, error) +} + +// RegisterHandlers registers all the supported endpoints related to exec APIs +func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Config, logger flog.Logger) { + h := newHandler(service, conf, logger) + + r.SetPrefix("/exec") + r.HandleFunc("/{id:.*}/start", h.start, http.MethodPost) + r.HandleFunc("/{id:.*}/resize", h.resize, http.MethodPost) + r.HandleFunc("/{id:.*}/json", h.inspect, http.MethodGet) +} + +// newHandler creates the handler that serves all exec APIs +func newHandler(service Service, conf *config.Config, logger flog.Logger) *handler { + return &handler{ + service: service, + config: conf, + logger: logger, + } +} + +type handler struct { + service Service + config *config.Config + logger flog.Logger +} + +// parseExecId breaks down execId into a container ID and process ID +func parseExecId(execId string) (string, string, error) { + splitId := strings.Split(execId, "/") + if len(splitId) != 2 { + return "", "", fmt.Errorf("invalid exec id: %s", execId) + } + + return splitId[0], splitId[1], nil +} diff --git a/pkg/api/handlers/exec/exec_test.go b/pkg/api/handlers/exec/exec_test.go new file mode 100644 index 00000000..1d927be4 --- /dev/null +++ b/pkg/api/handlers/exec/exec_test.go @@ -0,0 +1,73 @@ +package exec + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_exec" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// TestExecHandler is the entry point of the exec handler package's unit tests +func TestExecHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Exec APIs Handler") +} + +// Unit tests related to checking whether RegisterHandlers() has correctly configured the endpoints +var _ = Describe("Exec API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_exec.MockService + rr *httptest.ResponseRecorder + req *http.Request + conf config.Config + router *mux.Router + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_exec.NewMockService(mockCtrl) + router = mux.NewRouter() + RegisterHandlers(types.VersionedRouter{Router: router}, service, &conf, logger) + rr = httptest.NewRecorder() + }) + Context("handlers", func() { + It("should call exec start method", func() { + req, _ = http.NewRequest(http.MethodPost, "/exec/123/exec-123/start", bytes.NewReader([]byte(`{"detach": true}`))) + service.EXPECT().Inspect(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + service.EXPECT().Start(gomock.Any(), gomock.Any()).Return(errors.New("start error")) + + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "start error"}`)) + }) + It("should call exec resize method", func() { + req, _ = http.NewRequest(http.MethodPost, "/exec/123/exec-123/resize?h=123&w=123", nil) + service.EXPECT().Resize(gomock.Any(), gomock.Any()).Return(errors.New("resize error")) + + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "resize error"}`)) + }) + It("should call exec inspect method", func() { + service.EXPECT().Inspect(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("inspect error")) + req, _ = http.NewRequest(http.MethodGet, "/exec/123/exec-123/json", nil) + + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "inspect error"}`)) + }) + }) +}) diff --git a/pkg/api/handlers/exec/inspect.go b/pkg/api/handlers/exec/inspect.go new file mode 100644 index 00000000..32489fe4 --- /dev/null +++ b/pkg/api/handlers/exec/inspect.go @@ -0,0 +1,35 @@ +package exec + +import ( + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) inspect(w http.ResponseWriter, r *http.Request) { + execId := mux.Vars(r)["id"] + ctx := namespaces.WithNamespace(r.Context(), h.config.Namespace) + conId, procId, err := parseExecId(execId) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + + inspect, err := h.service.Inspect(ctx, conId, procId) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + default: + code = http.StatusInternalServerError + } + response.JSON(w, code, response.NewError(err)) + return + } + + response.JSON(w, http.StatusOK, inspect) +} diff --git a/pkg/api/handlers/exec/inspect_test.go b/pkg/api/handlers/exec/inspect_test.go new file mode 100644 index 00000000..e2fc315a --- /dev/null +++ b/pkg/api/handlers/exec/inspect_test.go @@ -0,0 +1,87 @@ +package exec + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_exec" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Exec Inspect API", func() { + var ( + mockCtrl *gomock.Controller + service *mocks_exec.MockService + conf config.Config + logger *mocks_logger.Logger + h *handler + rr *httptest.ResponseRecorder + req *http.Request + execInspect *types.ExecInspect + inspectStr []byte + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + service = mocks_exec.NewMockService(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + h = newHandler(service, &conf, logger) + rr = httptest.NewRecorder() + var err error + req, err = http.NewRequest(http.MethodGet, "/exec/123/exec-123/inspect", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123/exec-123"}) + execInspect = &types.ExecInspect{ + ID: "exec-123", + Running: true, + ExitCode: nil, + ProcessConfig: &types.ExecProcessConfig{ + Tty: true, + Entrypoint: "foo", + Arguments: []string{"bar", "baz"}, + Privileged: nil, + User: "test", + }, + OpenStdin: false, + OpenStdout: true, + OpenStderr: true, + CanRemove: true, + ContainerID: "123", + DetachKeys: nil, + Pid: 123, + } + inspectStr, err = json.Marshal(execInspect) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return 200 on successful inspect", func() { + service.EXPECT().Inspect(gomock.Any(), "123", "exec-123").Return(execInspect, nil) + + h.inspect(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr.Body).Should(MatchJSON(inspectStr)) + }) + It("should return 404 if the exec instance is not found", func() { + service.EXPECT().Inspect(gomock.Any(), "123", "exec-123").Return(nil, errdefs.NewNotFound(fmt.Errorf("inspect error"))) + + h.inspect(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "inspect error"}`)) + }) + It("should return 500 on any other error", func() { + service.EXPECT().Inspect(gomock.Any(), "123", "exec-123").Return(nil, fmt.Errorf("inspect error")) + + h.inspect(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "inspect error"}`)) + }) + }) +}) diff --git a/pkg/api/handlers/exec/resize.go b/pkg/api/handlers/exec/resize.go new file mode 100644 index 00000000..1b7cec53 --- /dev/null +++ b/pkg/api/handlers/exec/resize.go @@ -0,0 +1,68 @@ +package exec + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) resize(w http.ResponseWriter, r *http.Request) { + execId := mux.Vars(r)["id"] + ctx := namespaces.WithNamespace(r.Context(), h.config.Namespace) + height, err := getQueryParamInt(r, "h") + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + width, err := getQueryParamInt(r, "w") + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + + cid, procId, err := parseExecId(execId) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + + resizeOptions := &types.ExecResizeOptions{ + ConID: cid, + ExecID: procId, + Height: height, + Width: width, + } + if err := h.service.Resize(ctx, resizeOptions); err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + default: + code = http.StatusInternalServerError + } + response.JSON(w, code, response.NewError(err)) + return + } + + response.Status(w, http.StatusOK) + return +} + +// getQueryParamInt fetches an integer query parameter and throws an error if empty +func getQueryParamInt(r *http.Request, paramName string) (int, error) { + val := r.URL.Query().Get(paramName) + if val == "" { + return 0, fmt.Errorf("query parameter %s required", paramName) + } + if intValue, err := strconv.Atoi(val); err != nil { + return 0, fmt.Errorf("%s must be an integer", paramName) + } else { + return intValue, nil + } +} diff --git a/pkg/api/handlers/exec/resize_test.go b/pkg/api/handlers/exec/resize_test.go new file mode 100644 index 00000000..0be60624 --- /dev/null +++ b/pkg/api/handlers/exec/resize_test.go @@ -0,0 +1,124 @@ +package exec + +import ( + "errors" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_exec" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Exec Resize API", func() { + var ( + mockCtrl *gomock.Controller + service *mocks_exec.MockService + conf config.Config + logger *mocks_logger.Logger + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + service = mocks_exec.NewMockService(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + h = newHandler(service, &conf, logger) + rr = httptest.NewRecorder() + var err error + req, err = http.NewRequest(http.MethodPost, "/exec/123/exec-123/resize?h=123&w=123", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123/exec-123"}) + }) + Context("handler", func() { + It("should return 200 on successful resize", func() { + service.EXPECT().Resize(gomock.Any(), &types.ExecResizeOptions{ + ConID: "123", + ExecID: "exec-123", + Height: 123, + Width: 123, + }).Return(nil) + + h.resize(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 404 if the exec instance is not found", func() { + service.EXPECT().Resize(gomock.Any(), &types.ExecResizeOptions{ + ConID: "123", + ExecID: "exec-123", + Height: 123, + Width: 123, + }).Return(errdefs.NewNotFound(errors.New("not found"))) + + h.resize(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "not found"}`)) + }) + It("should return 500 on any other error", func() { + service.EXPECT().Resize(gomock.Any(), &types.ExecResizeOptions{ + ConID: "123", + ExecID: "exec-123", + Height: 123, + Width: 123, + }).Return(errors.New("inspect error")) + + h.resize(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "inspect error"}`)) + }) + It("should return 400 if h is not specified", func() { + badReq, err := http.NewRequest(http.MethodPost, "/exec/123/exec-123/resize?w=123", nil) + Expect(err).Should(BeNil()) + badReq = mux.SetURLVars(badReq, map[string]string{"id": "123/exec-123"}) + + h.resize(rr, badReq) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "query parameter h required"}`)) + }) + It("should return 400 if w is not specified", func() { + badReq, err := http.NewRequest(http.MethodPost, "/exec/123/exec-123/resize?h=123", nil) + Expect(err).Should(BeNil()) + badReq = mux.SetURLVars(badReq, map[string]string{"id": "123/exec-123"}) + + h.resize(rr, badReq) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "query parameter w required"}`)) + }) + It("should return 400 if a query param is not an int", func() { + badReq, err := http.NewRequest(http.MethodPost, "/exec/123/exec-123/resize?h=foo&w=123", nil) + Expect(err).Should(BeNil()) + badReq = mux.SetURLVars(badReq, map[string]string{"id": "123/exec-123"}) + + h.resize(rr, badReq) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "h must be an integer"}`)) + }) + }) + Context("getQueryParamInt", func() { + It("should correctly get h", func() { + height, err := getQueryParamInt(req, "h") + Expect(err).Should(BeNil()) + Expect(height).Should(Equal(123)) + }) + It("should return error if the query param does not exist", func() { + _, err := getQueryParamInt(req, "none") + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("query parameter none required")) + }) + It("should return error if the query param is not an integer", func() { + badReq, err := http.NewRequest(http.MethodPost, "/exec/123/exec-123/resize?foo=bar", nil) + Expect(err).Should(BeNil()) + + _, err = getQueryParamInt(badReq, "foo") + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("foo must be an integer")) + }) + }) +}) diff --git a/pkg/api/handlers/exec/start.go b/pkg/api/handlers/exec/start.go new file mode 100644 index 00000000..9e55922f --- /dev/null +++ b/pkg/api/handlers/exec/start.go @@ -0,0 +1,137 @@ +package exec + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/moby/moby/api/server/httputils" + "github.com/moby/moby/api/types/versions" + "github.com/moby/moby/pkg/stdcopy" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) start(w http.ResponseWriter, r *http.Request) { + var ( + execId = mux.Vars(r)["id"] + stdin io.ReadCloser + stdout, stderr io.Writer + printSuccessResponse func() + attachHeaderWritten = false + ) + ctx := namespaces.WithNamespace(r.Context(), h.config.Namespace) + + cid, procId, err := parseExecId(execId) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + + if r.Body == nil { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("body should not be empty")) + return + } + + execStartCheck := &types.ExecStartCheck{} + if err := json.NewDecoder(r.Body).Decode(execStartCheck); err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(fmt.Errorf("unable to parse request body: %w", err))) + return + } + + if !execStartCheck.Detach { + hijacker, ok := w.(http.Hijacker) + if !ok { + response.JSON(w, http.StatusInternalServerError, response.NewErrorFromMsg("not a http.Hijacker")) + return + } + + conn, _, err := hijacker.Hijack() + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + defer func() { + if conn != nil { + httputils.CloseStreams(conn) + } + }() + + // sets the connection to raw TCP mode. this will allow us to stream arbitrary data from the exec output over the connection + _, err = conn.Write([]byte{}) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + + _, upgrade := r.Header["Upgrade"] + successResponse := checkUpgradeStatus(ctx, upgrade) + + printSuccessResponse = func() { + fmt.Fprint(conn, successResponse) + // copy headers that were removed as part of hijack + w.Header().WriteSubset(conn, nil) + fmt.Fprint(conn, "\r\n") + attachHeaderWritten = true + } + + stdin = conn + stdout = stdcopy.NewStdWriter(conn, stdcopy.Stdout) + stderr = stdcopy.NewStdWriter(conn, stdcopy.Stderr) + } + + startOptions := &types.ExecStartOptions{ + ExecStartCheck: execStartCheck, + ConID: cid, + ExecID: procId, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + SuccessResponse: printSuccessResponse, + } + + if err := h.service.Start(ctx, startOptions); err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsConflict(err): + code = http.StatusConflict + default: + code = http.StatusInternalServerError + } + if execStartCheck.Detach { + response.JSON(w, code, response.NewError(err)) + return + } + if !attachHeaderWritten { + errResponse, _ := json.Marshal(response.NewError(err)) + fmt.Fprintf(stdout, "HTTP/1.1 %d %s\r\nContent-Type: application/json\r\n\r\n%s\r\n", code, http.StatusText(code), errResponse) + return + } + stdout.Write([]byte(err.Error() + "\r\n")) + return + } + + if execStartCheck.Detach { + response.Status(w, http.StatusOK) + } + return +} + +func checkUpgradeStatus(ctx context.Context, upgrade bool) string { + contentType := "application/vnd.docker.raw-stream" + if upgrade { + if versions.GreaterThanOrEqualTo(httputils.VersionFromContext(ctx), "1.42") { + contentType = "application/vnd.docker.multiplexed-stream" + } + return fmt.Sprintf("HTTP/1.1 101 UPGRADED\r\nContent-Type: %s\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n", contentType) + } else { + return fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: %s\r\n", contentType) + } +} diff --git a/pkg/api/handlers/exec/start_test.go b/pkg/api/handlers/exec/start_test.go new file mode 100644 index 00000000..6ba2755f --- /dev/null +++ b/pkg/api/handlers/exec/start_test.go @@ -0,0 +1,297 @@ +package exec + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + hj "github.com/getlantern/httptest" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_exec" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Exec Start API", func() { + var ( + mockCtrl *gomock.Controller + service *mocks_exec.MockService + conf config.Config + logger *mocks_logger.Logger + h *handler + rr *httptest.ResponseRecorder + opts *types.ExecStartCheck + req *http.Request + startOpts *types.ExecStartOptions + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + service = mocks_exec.NewMockService(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + h = newHandler(service, &conf, logger) + rr = httptest.NewRecorder() + }) + Context("handler", func() { + Context("bad request", func() { + It("should return 400 if the request body is empty", func() { + var err error + req, err = http.NewRequest(http.MethodPost, "/exec/123/exec-123/start", nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123/exec-123"}) + + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "body should not be empty"}`)) + }) + It("should return 400 if the body reader returns an error", func() { + var err error + req, err = http.NewRequest(http.MethodPost, "/exec/123/exec-123/start", NewErrorReader()) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123/exec-123"}) + + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "unable to parse request body: read error"}`)) + }) + It("should return 400 if the request body is not an ExecStartCheck", func() { + var err error + req, err = http.NewRequest(http.MethodPost, "/exec/123/exec-123/start", bytes.NewReader([]byte("foo"))) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123/exec-123"}) + + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "unable to parse request body: invalid character 'o' in literal false (expecting 'a')"}`)) + }) + }) + Context("detach == true", func() { + BeforeEach(func() { + opts = &types.ExecStartCheck{ + Detach: true, + Tty: false, + ConsoleSize: &[2]uint{123, 123}, + } + reqBody, err := json.Marshal(opts) + Expect(err).Should(BeNil()) + req, err = http.NewRequest(http.MethodPost, "/exec/123/exec-123/start", bytes.NewReader(reqBody)) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123/exec-123"}) + startOpts = &types.ExecStartOptions{ + ExecStartCheck: opts, + ConID: "123", + ExecID: "exec-123", + Stdin: nil, + Stdout: nil, + Stderr: nil, + } + }) + It("should return 200 on successful start", func() { + service.EXPECT().Start(gomock.Any(), startOpts).Return(nil) + + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 404 if the exec instance is not found", func() { + service.EXPECT().Start(gomock.Any(), startOpts).Return(errdefs.NewNotFound(fmt.Errorf("not found"))) + + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body).Should(MatchJSON(`{"message": "not found"}`)) + }) + It("should return 500 if it fails to start", func() { + service.EXPECT().Start(gomock.Any(), startOpts).Return(fmt.Errorf("failed to start")) + + h.start(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "failed to start"}`)) + }) + }) + XContext("detach == false", func() { + var rr *hj.HijackableResponseRecorder + + BeforeEach(func() { + opts = &types.ExecStartCheck{ + Detach: false, + Tty: true, + ConsoleSize: &[2]uint{123, 123}, + } + reqBody, err := json.Marshal(opts) + Expect(err).Should(BeNil()) + req, err = http.NewRequest(http.MethodPost, "/exec/123/exec-123/start", bytes.NewReader(reqBody)) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"id": "123/exec-123"}) + rr = hj.NewRecorder(nil) + }) + It("should return 500 if Hijacking the connection fails", func() { + hijackErrMsg := "error hijacking the connection" + errRR := newResponseRecorderWithMockHijack(rr, func() (net.Conn, *bufio.ReadWriter, error) { + return nil, nil, fmt.Errorf(hijackErrMsg) + }) + + h.start(errRR, req) + Expect(rr.Code()).Should(Equal(http.StatusInternalServerError)) + Expect(rr.Body().Bytes()).Should(MatchJSON(fmt.Sprintf(`{"message": "%s"}`, hijackErrMsg))) + }) + It("should return 404 if the exec instance is not found", func() { + service.EXPECT().Start(gomock.Any(), execStartOptionsWithIdsAndCheck("123", "exec-123", opts)).Return(errdefs.NewNotFound(fmt.Errorf("not found"))) + + h.start(rr, req) + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal("HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\n\r\n{\"message\":\"not found\"}\r\n")) + }) + It("should return 409 if the container is not running", func() { + service.EXPECT().Start(gomock.Any(), execStartOptionsWithIdsAndCheck("123", "exec-123", opts)).Return(errdefs.NewConflict(fmt.Errorf("not running"))) + + h.start(rr, req) + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal("HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\n\r\n{\"message\":\"not running\"}\r\n")) + }) + It("should return 500 on other errors", func() { + service.EXPECT().Start(gomock.Any(), execStartOptionsWithIdsAndCheck("123", "exec-123", opts)).Return(fmt.Errorf("start error")) + + h.start(rr, req) + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal("HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"message\":\"start error\"}\r\n")) + }) + It("should correctly upgrade if requested", func() { + req.Header.Set("Upgrade", "foo") + service.EXPECT().Start(gomock.Any(), execStartOptionsWithIdsAndCheck("123", "exec-123", opts)).DoAndReturn( + func(ctx context.Context, execStartOptions *types.ExecStartOptions) error { + execStartOptions.SuccessResponse() + return nil + }) + + h.start(rr, req) + + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal(fmt.Sprintf("HTTP/1.1 101 UPGRADED\r\nContent-Type: %s\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n", + "application/vnd.docker.raw-stream"))) + }) + It("should stream output from the started process", func() { + service.EXPECT().Start(gomock.Any(), execStartOptionsWithIdsAndCheck("123", "exec-123", opts)).DoAndReturn( + func(ctx context.Context, execStartOptions *types.ExecStartOptions) error { + execStartOptions.SuccessResponse() + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n")) + rr.Body().Reset() + + responses := [3]string{"foo", "bar", "baz"} + for i, response := range responses { + fmt.Fprint(execStartOptions.Stdout, response) + rrBody = (*(rr.Body())).String() + Expect(rrBody).Should(Equal(responses[i])) + rr.Body().Reset() + } + return nil + }) + + h.start(rr, req) + }) + It("should print any errors from Start to the connection if the success response has been returned", func() { + service.EXPECT().Start(gomock.Any(), execStartOptionsWithIdsAndCheck("123", "exec-123", opts)).DoAndReturn( + func(ctx context.Context, execStartOptions *types.ExecStartOptions) error { + execStartOptions.SuccessResponse() + return fmt.Errorf("start error") + }) + + h.start(rr, req) + rrBody := (*(rr.Body())).String() + Expect(rrBody).Should(Equal("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\nstart error\r\n")) + }) + }) + }) +}) + +func NewErrorReader() io.Reader { + return &errorReader{} +} + +type errorReader struct{} + +func (r *errorReader) Read(_ []byte) (int, error) { + return 0, fmt.Errorf("read error") +} + +func newResponseRecorderWithMockHijack(wrapped http.ResponseWriter, mockHijack func() (net.Conn, *bufio.ReadWriter, error)) *responseRecorderWithMockHijack { + h := &responseRecorderWithMockHijack{ + wrapped: wrapped, + mockHijack: mockHijack, + } + return h +} + +type responseRecorderWithMockHijack struct { + wrapped http.ResponseWriter + mockHijack func() (net.Conn, *bufio.ReadWriter, error) +} + +func (h *responseRecorderWithMockHijack) Header() http.Header { + return h.wrapped.Header() +} + +func (h *responseRecorderWithMockHijack) Write(bytes []byte) (int, error) { + return h.wrapped.Write(bytes) +} + +func (h *responseRecorderWithMockHijack) WriteHeader(statusCode int) { + h.wrapped.WriteHeader(statusCode) +} + +func (h *responseRecorderWithMockHijack) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return h.mockHijack() +} + +func execStartOptionsWithIdsAndCheck(conId string, execId string, check *types.ExecStartCheck) gomock.Matcher { + return &execStartOptionsMatcher{ + conId: conId, + execId: execId, + check: check, + } +} + +type execStartOptionsMatcher struct { + conId string + execId string + check *types.ExecStartCheck +} + +func (m *execStartOptionsMatcher) Matches(x interface{}) bool { + matchOpts, ok := x.(*types.ExecStartOptions) + if !ok { + return false + } + if m.conId != matchOpts.ConID { + return false + } + if m.execId != matchOpts.ExecID { + return false + } + if m.check.Detach != matchOpts.Detach { + return false + } + if m.check.Tty != matchOpts.Tty { + return false + } + if m.check.ConsoleSize[0] != matchOpts.ConsoleSize[0] { + return false + } + if m.check.ConsoleSize[1] != matchOpts.ConsoleSize[1] { + return false + } + return true +} + +func (m *execStartOptionsMatcher) String() string { + return fmt.Sprintf("*types.ExecStartOptions with ConId: %s, ExecId: %s, ExecStartCheck: %v", m.conId, m.execId, m.check) +} diff --git a/pkg/api/handlers/image/image.go b/pkg/api/handlers/image/image.go new file mode 100644 index 00000000..3876362f --- /dev/null +++ b/pkg/api/handlers/image/image.go @@ -0,0 +1,49 @@ +package image + +import ( + "context" + "io" + "net/http" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +//go:generate mockgen --destination=../../../mocks/mocks_image/imagesvc.go -package=mocks_image github.com/runfinch/finch-daemon/pkg/api/handlers/image Service +type Service interface { + List(ctx context.Context) ([]types.ImageSummary, error) + Pull(ctx context.Context, name, tag, platform string, authCfg *dockertypes.AuthConfig, outStream io.Writer) error + Push(ctx context.Context, name, tag string, authCfg *dockertypes.AuthConfig, outStream io.Writer) (*types.PushResult, error) + Remove(ctx context.Context, name string, force bool) (deleted, untagged []string, err error) + Tag(ctx context.Context, srcImg string, repo, tag string) error + Inspect(ctx context.Context, name string) (*dockercompat.Image, error) +} + +func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Config, logger flog.Logger) { + h := newHandler(service, conf, logger) + + r.SetPrefix("/images") + r.HandleFunc("/create", h.pull, http.MethodPost) + r.HandleFunc("/json", h.list, http.MethodGet) + r.HandleFunc("/{name:.*}", h.remove, http.MethodDelete) + r.HandleFunc("/{name:.*}/push", h.push, http.MethodPost) + r.HandleFunc("/{name:.*}/tag", h.tag, http.MethodPost) + r.HandleFunc("/{name:.*}/json", h.inspect, http.MethodGet) +} + +func newHandler(service Service, conf *config.Config, logger flog.Logger) *handler { + return &handler{ + service: service, + Config: conf, + logger: logger, + } +} + +type handler struct { + service Service + Config *config.Config + logger flog.Logger +} diff --git a/pkg/api/handlers/image/image_test.go b/pkg/api/handlers/image/image_test.go new file mode 100644 index 00000000..bd54c28a --- /dev/null +++ b/pkg/api/handlers/image/image_test.go @@ -0,0 +1,75 @@ +package image + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_image" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// TestImageHandler function is the entry point of image handler package's unit test using ginkgo +func TestImageHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Image APIs Handler") +} + +// Unit tests related to check RegisterHandlers() has configured the endpoint properly for image related APIs +var _ = Describe("Image API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_image.MockService + rr *httptest.ResponseRecorder + req *http.Request + conf config.Config + router *mux.Router + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_image.NewMockService(mockCtrl) + router = mux.NewRouter() + RegisterHandlers(types.VersionedRouter{Router: router}, service, &conf, logger) + rr = httptest.NewRecorder() + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + }) + Context("handler", func() { + It("should call image inspect method", func() { + // setup mocks + service.EXPECT().Inspect(gomock.Any(), "test-image").Return(nil, errors.New("error from inspect api")) + req, _ = http.NewRequest(http.MethodGet, "/images/test-image/json", nil) + // call the API to check if it returns the error generated from the inspect method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from inspect api"}`)) + }) + It("should call image pull method", func() { + // setup mocks + service.EXPECT().Pull( + gomock.Any(), + "test-image", + "test-tag", + "test-platform", + gomock.Any(), + gomock.Any(), + ).Return(errors.New("error from pull api")) + req, _ = http.NewRequest(http.MethodPost, "/images/create?fromImage=test-image&tag=test-tag&platform=test-platform", nil) + // call the API to check if it returns the error generated from the pull method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from pull api"}`)) + }) + }) +}) diff --git a/pkg/api/handlers/image/inspect.go b/pkg/api/handlers/image/inspect.go new file mode 100644 index 00000000..8467b6e3 --- /dev/null +++ b/pkg/api/handlers/image/inspect.go @@ -0,0 +1,32 @@ +package image + +import ( + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) inspect(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + image, err := h.service.Inspect(ctx, name) + // map the error into http status code and send response. + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + default: + code = http.StatusInternalServerError + } + h.logger.Debugf("Inspect Image API failed. Status code %d, Message: %s", code, err) + response.SendErrorResponse(w, code, err) + return + } + + // return JSON response + response.JSON(w, http.StatusOK, image) +} diff --git a/pkg/api/handlers/image/inspect_test.go b/pkg/api/handlers/image/inspect_test.go new file mode 100644 index 00000000..01a55297 --- /dev/null +++ b/pkg/api/handlers/image/inspect_test.go @@ -0,0 +1,84 @@ +package image + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_image" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Image Inspect API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_image.MockService + h *handler + rr *httptest.ResponseRecorder + name string + req *http.Request + resp dockercompat.Image + respJSON []byte + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_image.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + name = "test-image" + var err error + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/images/%s/json", name), nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"name": name}) + resp = dockercompat.Image{ + ID: name, + RepoTags: []string{"test-image:latest"}, + RepoDigests: []string{"test-image@test-digest"}, + Size: 100, + } + respJSON, err = json.Marshal(resp) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return inspect object and 200 status code upon success", func() { + service.EXPECT().Inspect(gomock.Any(), name).Return(&resp, nil) + + // handler should return response object with 200 status code + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(respJSON)) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 404 status code if image was not found", func() { + service.EXPECT().Inspect(gomock.Any(), name).Return(nil, errdefs.NewNotFound(fmt.Errorf("no such image"))) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // handler should return error message with 404 status code + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "no such image"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + It("should return 500 status code if service returns an error message", func() { + service.EXPECT().Inspect(gomock.Any(), name).Return(nil, fmt.Errorf("error")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // handler should return error message + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + }) + +}) diff --git a/pkg/api/handlers/image/list.go b/pkg/api/handlers/image/list.go new file mode 100644 index 00000000..b347f285 --- /dev/null +++ b/pkg/api/handlers/image/list.go @@ -0,0 +1,16 @@ +package image + +import ( + "net/http" + + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +func (h *handler) list(w http.ResponseWriter, r *http.Request) { + resp, err := h.service.List(r.Context()) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + response.JSON(w, http.StatusOK, resp) +} diff --git a/pkg/api/handlers/image/pull.go b/pkg/api/handlers/image/pull.go new file mode 100644 index 00000000..9483a168 --- /dev/null +++ b/pkg/api/handlers/image/pull.go @@ -0,0 +1,108 @@ +package image + +import ( + "fmt" + "net/http" + "regexp" + + "github.com/containerd/containerd/namespaces" + "github.com/runfinch/finch-daemon/pkg/api/auth" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// The /images/create API pulls the image specified by given name and tag. +// Importing images is not supported. +func (h *handler) pull(w http.ResponseWriter, r *http.Request) { + warnings := handleUnsupportedParams(r) + for warning := range warnings { + h.logger.Warn(warning) + } + + // get auth creds from header + authCfg, err := auth.DecodeAuthConfig(r.Header.Get(auth.AuthHeader)) + if err != nil { + response.SendErrorResponse(w, http.StatusBadRequest, fmt.Errorf("failed to decode auth header: %s", err)) + return + } + + // image name + name, tag, err := parseNameAndTag(r) + if err != nil { + response.SendErrorResponse(w, http.StatusBadRequest, err) + return + } + + platform := r.URL.Query().Get("platform") + + // start the pull job and send status updates to the response writer as JSON stream + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + streamWriter := response.NewPullJobWriter(w) + err = h.service.Pull(ctx, name, tag, platform, authCfg, streamWriter) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsInvalidFormat(err): + code = http.StatusBadRequest + default: + code = http.StatusInternalServerError + } + h.logger.Debugf("Create Image API failed. Status code %d, Message: %s", code, err) + streamWriter.WriteError(code, err) + return + } + streamWriter.Write([]byte(fmt.Sprintf("Pulled %s:%s\n", name, tag))) +} + +func handleUnsupportedParams(r *http.Request) []string { + // unsupported query parameters: fromSrc, repo, message, changes. + // fromSrc, repo, and message are only used when importing images. + warnings := []string{} + if r.URL.Query().Get("fromSrc") != "" { + warnings = append(warnings, "fromSrc parameter specified but importing images is not supported") + } + if r.URL.Query().Get("repo") != "" { + warnings = append(warnings, "repo parameter specified but importing images is not supported") + } + if r.URL.Query().Get("message") != "" { + warnings = append(warnings, "message parameter specified but importing images is not supported") + } + if r.URL.Query().Get("changes") != "" { + warnings = append(warnings, "changes parameter is not supported") + } + + return warnings +} + +var splitRE = regexp.MustCompile(`[@:]`) + +func parseNameAndTag(r *http.Request) (string, string, error) { + // image name + nameParam := r.URL.Query().Get("fromImage") + if nameParam == "" { + return "", "", fmt.Errorf("fromImage must be specified") + } + + // fromImage parameter may include image tag/digest + parts := splitRE.Split(nameParam, 2) + name := parts[0] + if name == "" { + return "", "", fmt.Errorf("invalid image: %s", nameParam) + } + var tag string + if len(parts) > 1 { + tag = parts[1] + } + + // image tag + if tagParam := r.URL.Query().Get("tag"); tagParam != "" { + tag = tagParam + } + if tag == "" { + return "", "", fmt.Errorf("image tag/digest must be specified") + } + + return name, tag, nil +} diff --git a/pkg/api/handlers/image/pull_test.go b/pkg/api/handlers/image/pull_test.go new file mode 100644 index 00000000..8a310233 --- /dev/null +++ b/pkg/api/handlers/image/pull_test.go @@ -0,0 +1,323 @@ +package image + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_image" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Image Pull API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_image.MockService + h *handler + rr *httptest.ResponseRecorder + name string + tag string + platform string + ) + BeforeEach(func() { + //initialize mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_image.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + name = "test-image" + tag = "test-tag" + platform = "test-platform" + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + }) + Context("handler", func() { + It("should return 200 status code upon success", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&tag=%s", name, tag), + nil, + ) + Expect(err).Should(BeNil()) + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + "", + gomock.Any(), + gomock.Any(), + ).Return(nil) + + // handler should return 200 status code + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 200 status code upon success with platform specification", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&tag=%s&platform=%s", name, tag, platform), + nil, + ) + Expect(err).Should(BeNil()) + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + platform, + gomock.Any(), + gomock.Any(), + ).Return(nil) + + // handler should return 200 status code + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 200 status code upon success with authentication", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&tag=%s", name, tag), + nil, + ) + Expect(err).Should(BeNil()) + authB64 := base64.StdEncoding.EncodeToString([]byte(`{"username": "test-user", "password": "test-password"}`)) + req.Header.Set("X-Registry-Auth", authB64) + + // expected decoded auth config + authCfg := dockertypes.AuthConfig{ + Username: "test-user", + Password: "test-password", + } + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + "", + &authCfg, + gomock.Any(), + ).Return(nil) + + // handler should return 200 status code + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 200 status code upon success when digest is specified", func() { + tag := "sha256:7ea94d4e7f346a9328a9ff053ab149e3c99c1737f8d251094e7cc38664c3d4b9" + nameWithDigest := fmt.Sprintf("%s@%s", name, tag) + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&tag=%s", nameWithDigest, tag), + nil, + ) + Expect(err).Should(BeNil()) + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + "", + gomock.Any(), + gomock.Any(), + ).Return(nil) + + // handler should return 200 status code + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 404 status code if image could not be resolved", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&tag=%s&platform=%s", name, tag, platform), + nil, + ) + Expect(err).Should(BeNil()) + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + platform, + gomock.Any(), + gomock.Any(), + ).Return(errdefs.NewNotFound(fmt.Errorf("no such image"))) + + // handler should return error message with 404 status code + h.pull(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "no such image"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + It("should return 500 status code if service returns an error message", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&tag=%s&platform=%s", name, tag, platform), + nil, + ) + Expect(err).Should(BeNil()) + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + platform, + gomock.Any(), + gomock.Any(), + ).Return(fmt.Errorf("error")) + + // handler should return error message with 500 status code + h.pull(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + It("should log warnings if unsupported parameters are specified", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&fromSrc=%s&tag=%s&change=abcd", name, name, tag), + nil, + ) + Expect(err).Should(BeNil()) + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + "", + gomock.Any(), + gomock.Any(), + ).Return(nil) + + logger.EXPECT().Warn(gomock.Any()).Times(2) + + // handler should return error message with 400 status code + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 400 status code if image is not specified", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create"), + nil, + ) + Expect(err).Should(BeNil()) + + // handler should return error message with 400 status code + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + It("should return 400 status code if tag is not specified", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s", name), + nil, + ) + Expect(err).Should(BeNil()) + + // handler should return error message with 400 status code + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + }) + It("should stream pull updates upon success", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&tag=%s", name, tag), + nil, + ) + Expect(err).Should(BeNil()) + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + "", + gomock.Any(), + gomock.Any(), + ).DoAndReturn(func(_ context.Context, _, _, _ string, _ *dockertypes.AuthConfig, sw io.Writer) error { + sw.Write([]byte("this message should be ignored\n")) + sw.Write([]byte("resolved image\n")) // the streamwriter will start streaming when a message contains substring "resolved" + sw.Write([]byte("pulling image\n")) + sw.Write([]byte("pulling complete\n")) + return nil + }) + + // handler should return 200 status code with streamed updates + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + + // expected stream output + scanner := bufio.NewScanner(rr.Body) + outputs := []response.StreamResponse{} + for scanner.Scan() { + var stream response.StreamResponse + err = json.Unmarshal(scanner.Bytes(), &stream) + Expect(err).Should(BeNil()) + outputs = append(outputs, stream) + } + Expect(len(outputs)).Should(Equal(4)) + Expect(outputs[0]).Should(Equal(response.StreamResponse{Stream: "resolved image\n"})) + Expect(outputs[1]).Should(Equal(response.StreamResponse{Stream: "pulling image\n"})) + Expect(outputs[2]).Should(Equal(response.StreamResponse{Stream: "pulling complete\n"})) + Expect(outputs[3]).Should(Equal(response.StreamResponse{Stream: fmt.Sprintf("Pulled %s:%s\n", name, tag)})) + }) + It("should send 200 status code and display an error message after streaming", func() { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/create?fromImage=%s&tag=%s", name, tag), + nil, + ) + Expect(err).Should(BeNil()) + + service.EXPECT().Pull( + gomock.Any(), + name, + tag, + "", + gomock.Any(), + gomock.Any(), + ).DoAndReturn(func(_ context.Context, _, _, _ string, _ *dockertypes.AuthConfig, sw io.Writer) error { + sw.Write([]byte("this message should be ignored\n")) + sw.Write([]byte("resolved image\n")) // the streamwriter will start streaming when a message contains substring "resolved" + sw.Write([]byte("pulling image\n")) + return fmt.Errorf("error pulling") + }) + + // handler should return 200 status code with streamed updates + h.pull(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + + // expected stream output + scanner := bufio.NewScanner(rr.Body) + outputs := []response.StreamResponse{} + for scanner.Scan() { + var stream response.StreamResponse + err = json.Unmarshal(scanner.Bytes(), &stream) + Expect(err).Should(BeNil()) + outputs = append(outputs, stream) + } + Expect(len(outputs)).Should(Equal(3)) + Expect(outputs[0]).Should(Equal(response.StreamResponse{Stream: "resolved image\n"})) + Expect(outputs[1]).Should(Equal(response.StreamResponse{Stream: "pulling image\n"})) + Expect(outputs[2]).Should(Equal(response.StreamResponse{ + Error: &jsonmessage.JSONError{Code: http.StatusInternalServerError, Message: "error pulling"}, + ErrorMessage: "error pulling", + })) + }) + }) + +}) diff --git a/pkg/api/handlers/image/push.go b/pkg/api/handlers/image/push.go new file mode 100644 index 00000000..7aad04a9 --- /dev/null +++ b/pkg/api/handlers/image/push.go @@ -0,0 +1,49 @@ +package image + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/auth" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) push(w http.ResponseWriter, r *http.Request) { + authCfg, err := auth.DecodeAuthConfig(r.Header.Get(auth.AuthHeader)) + if err != nil { + response.SendErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to decode the auth header: %w", err)) + return + } + + // start the push job and send status updates to the response writer as JSON stream + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + streamWriter := response.NewStreamWriter(w) + result, err := h.service.Push(ctx, mux.Vars(r)["name"], r.URL.Query().Get("tag"), authCfg, streamWriter) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsInvalidFormat(err): + code = http.StatusBadRequest + default: + code = http.StatusInternalServerError + } + h.logger.Debugf("Push Image API failed. Status code %d, Message: %s", code, err) + streamWriter.WriteError(code, err) + return + } + + // send push result as out-of-band aux data + if result != nil { + auxData, err := json.Marshal(result) + if err != nil { + return + } + streamWriter.WriteAux(auxData) + } +} diff --git a/pkg/api/handlers/image/push_test.go b/pkg/api/handlers/image/push_test.go new file mode 100644 index 00000000..a29c070a --- /dev/null +++ b/pkg/api/handlers/image/push_test.go @@ -0,0 +1,178 @@ +package image + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/auth" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_image" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Image Push API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_image.MockService + h *handler + req *http.Request + rr *httptest.ResponseRecorder + name string + tag string + result *types.PushResult + auxMsg json.RawMessage + ) + BeforeEach(func() { + //initialize mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_image.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + name = "test-image" + tag = "test-tag" + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + var err error + req, err = http.NewRequest( + http.MethodPost, + fmt.Sprintf("/images/%s/push?tag=%s", name, tag), + nil, + ) + Expect(err).ShouldNot(HaveOccurred()) + req = mux.SetURLVars(req, map[string]string{"name": name}) + authB64 := base64.StdEncoding.EncodeToString([]byte(`{"username": "test-user", "password": "test-password"}`)) + req.Header.Set(auth.AuthHeader, authB64) + result = &types.PushResult{ + Tag: tag, + Digest: "test-digest", + Size: 256, + } + auxData, err := json.Marshal(result) + Expect(err).ShouldNot(HaveOccurred()) + auxMsg = json.RawMessage(auxData) + + }) + Context("handler", func() { + It("should return 200 status code and stream output upon success", func() { + + // expected decoded auth config + expectedAuthCfg := dockertypes.AuthConfig{ + Username: "test-user", + Password: "test-password", + } + + // stream messages + streamMsg := []string{"Pushing image", "Pushed"} + + service.EXPECT().Push(gomock.Any(), name, tag, gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, name, tag string, authCfg *dockertypes.AuthConfig, outStream io.Writer) (*types.PushResult, error) { + Expect(authCfg.Username).Should(Equal(expectedAuthCfg.Username)) + Expect(authCfg.Password).Should(Equal(expectedAuthCfg.Password)) + for _, msg := range streamMsg { + outStream.Write([]byte(msg)) + } + return result, nil + }) + + // handler should return 200 status code + h.push(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + + // expected output stream + scanner := bufio.NewScanner(rr.Body) + outputs := []response.StreamResponse{} + for scanner.Scan() { + var stream response.StreamResponse + err := json.Unmarshal(scanner.Bytes(), &stream) + Expect(err).Should(BeNil()) + outputs = append(outputs, stream) + } + Expect(len(outputs)).Should(Equal(len(streamMsg) + 1)) + for i, msg := range streamMsg { + Expect(outputs[i]).Should(Equal(response.StreamResponse{Stream: msg})) + } + Expect(outputs[len(outputs)-1]).Should(Equal(response.StreamResponse{Aux: &auxMsg})) + }) + It("should return 500 status code due to invalid auth header", func() { + req.Header.Set(auth.AuthHeader, "Invalid token") + + // handler should return 500 status code + h.push(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + It("should return 404 status code if image could not be resolved", func() { + service.EXPECT().Push( + gomock.Any(), + name, + tag, + gomock.Any(), + gomock.Any(), + ).Return(nil, errdefs.NewNotFound(fmt.Errorf("no such image"))) + + // handler should return error message with 404 status code + h.push(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "no such image"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + It("should return 500 status code if service returns an error message", func() { + service.EXPECT().Push( + gomock.Any(), + name, + tag, + gomock.Any(), + gomock.Any(), + ).Return(nil, fmt.Errorf("some error")) + + // handler should return error message with 500 status code + h.push(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "some error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + It("should return 200 status code but return auth error message as stream response", func() { + streamMsg := "Pushing image" + errMsg := "auth error" + + // pass empty auth header. + req.Header.Set(auth.AuthHeader, "") + service.EXPECT().Push( + gomock.Any(), + name, + tag, + gomock.Any(), + gomock.Any(), + ).DoAndReturn(func(ctx context.Context, name, tag string, authCfg *dockertypes.AuthConfig, outStream io.Writer) (*types.PushResult, error) { + // username and password should be empty + Expect(authCfg.Username).Should(BeEmpty()) + Expect(authCfg.Password).Should(BeEmpty()) + // mimic service is trying to push the image and failed with auth error. + outStream.Write([]byte(streamMsg)) + return nil, fmt.Errorf(errMsg) + }) + + // handler should return error message with 500 status code + h.push(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(data[:])).Should(ContainSubstring(streamMsg)) + Expect(string(data[:])).Should(And(ContainSubstring(errMsg), ContainSubstring(`"errorDetail"`))) + }) + }) +}) diff --git a/pkg/api/handlers/image/remove.go b/pkg/api/handlers/image/remove.go new file mode 100644 index 00000000..3e9cc89c --- /dev/null +++ b/pkg/api/handlers/image/remove.go @@ -0,0 +1,52 @@ +package image + +import ( + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +const ( + // TODO: Figure out how to add removeResponseUntaggedImage to the response. + removeResponseUntaggedKey = "Untagged" + removeResponseDeletedKey = "Deleted" +) + +func (i *handler) remove(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + f := r.URL.Query().Get("force") + force, err := strconv.ParseBool(f) + if err != nil { + force = false + } + deleted, untagged, err := i.service.Remove(r.Context(), name, force) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsConflict(err): + code = http.StatusConflict + default: + code = http.StatusInternalServerError + } + response.SendErrorResponse(w, code, err) + return + } + response.JSON(w, http.StatusOK, i.buildRemoveResp(untagged, deleted)) +} + +func (*handler) buildRemoveResp(untagged, deleted []string) []map[string]string { + resp := make([]map[string]string, 0, len(deleted)+len(untagged)) + push := func(key string, items []string) { + for _, item := range items { + resp = append(resp, map[string]string{key: item}) + } + } + push(removeResponseUntaggedKey, untagged) + push(removeResponseDeletedKey, deleted) + return resp +} diff --git a/pkg/api/handlers/image/remove_test.go b/pkg/api/handlers/image/remove_test.go new file mode 100644 index 00000000..22a976d5 --- /dev/null +++ b/pkg/api/handlers/image/remove_test.go @@ -0,0 +1,101 @@ +package image + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_image" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Image Remove API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_image.MockService + h *handler + rr *httptest.ResponseRecorder + name string + req *http.Request + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_image.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + name = "test-image" + var err error + req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/images/%s", name), nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"name": name}) + }) + Context("handler", func() { + It("should return 200 status code upon success", func() { + service.EXPECT().Remove(gomock.Any(), name, false).Return([]string{"12345"}, []string{"12345"}, nil) + + // handler should return response object with 200 status code + h.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 404 status code if image was not found", func() { + service.EXPECT().Remove(gomock.Any(), name, false).Return(nil, nil, errdefs.NewNotFound(fmt.Errorf("no such image"))) + + // handler should return error message with 404 status code + h.remove(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "no such image"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + It("should return 409 status code if image is being used", func() { + service.EXPECT().Remove(gomock.Any(), name, false).Return(nil, nil, + errdefs.NewConflict(fmt.Errorf("in use"))) + + // handler should return error message with 409 status code + h.remove(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "in use"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusConflict)) + }) + It("should return 500 status code if service returns an error message", func() { + service.EXPECT().Remove(gomock.Any(), name, false).Return(nil, nil, fmt.Errorf("error")) + + // handler should return error message + h.remove(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + It("should pass force flag as true to service", func() { + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/images/%s?force=true", name), nil) + req = mux.SetURLVars(req, map[string]string{"name": name}) + + Expect(err).Should(BeNil()) + service.EXPECT().Remove(gomock.Any(), name, true).Return([]string{"12345"}, []string{"12345"}, nil) + + // handler should return response object with 200 status code + h.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should pass force flag as false to service for invalid value", func() { + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/images/%s?force=asdf", name), nil) + req = mux.SetURLVars(req, map[string]string{"name": name}) + + Expect(err).Should(BeNil()) + service.EXPECT().Remove(gomock.Any(), name, false).Return([]string{"12345"}, []string{"12345"}, nil) + + // handler should return response object with 200 status code + h.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + + }) + +}) diff --git a/pkg/api/handlers/image/tag.go b/pkg/api/handlers/image/tag.go new file mode 100644 index 00000000..49868f16 --- /dev/null +++ b/pkg/api/handlers/image/tag.go @@ -0,0 +1,25 @@ +package image + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) tag(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + name := params["name"] + repo := r.URL.Query().Get("repo") + tag := r.URL.Query().Get("tag") + err := h.service.Tag(r.Context(), name, repo, tag) + if errdefs.IsNotFound(err) { + response.JSON(w, http.StatusNotFound, response.NewError(err)) + return + } else if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + response.JSON(w, http.StatusCreated, "No error") +} diff --git a/pkg/api/handlers/network/connect.go b/pkg/api/handlers/network/connect.go new file mode 100644 index 00000000..e49b6323 --- /dev/null +++ b/pkg/api/handlers/network/connect.go @@ -0,0 +1,38 @@ +package network + +import ( + "encoding/json" + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// From https://github.com/moby/moby/blob/v23.0.3/api/types/types.go#L634-L638 +// NetworkConnect represents the data to be used to connect a container to the network +type networkConnect struct { + Container string + // TODO: EndpointConfig *network.EndpointSettings `json:",omitempty"` +} + +func (h *handler) connect(w http.ResponseWriter, r *http.Request) { + var req networkConnect + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + + ctx := namespaces.WithNamespace(r.Context(), h.config.Namespace) + err := h.service.Connect(ctx, mux.Vars(r)["id"], req.Container) + if err != nil { + if errdefs.IsNotFound(err) { + response.JSON(w, http.StatusNotFound, response.NewError(err)) + return + } + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + response.Status(w, http.StatusOK) +} diff --git a/pkg/api/handlers/network/create.go b/pkg/api/handlers/network/create.go new file mode 100644 index 00000000..aaec2308 --- /dev/null +++ b/pkg/api/handlers/network/create.go @@ -0,0 +1,62 @@ +package network + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/containerd/containerd/namespaces" + respond "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (h *handler) create(w http.ResponseWriter, r *http.Request) { + ctx := namespaces.WithNamespace(r.Context(), h.config.Namespace) + + var request types.NetworkCreateRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + h.logger.Errorf("Failed decoding create network request: %v", err) + respond.SendErrorResponse(w, http.StatusBadRequest, err) + return + } + + isMissingARequiredField := func(r types.NetworkCreateRequest) bool { + // The Docker API specification only requires network name. + return r.Name == "" + } + + if isMissingARequiredField(request) { + // The request is valid JSON, but missing a required field. + h.logger.Warn("Create network request received missing required field.") + + // Docker Engine returns 500 Internal Server Error in such an instance. + respond.SendErrorResponse(w, http.StatusInternalServerError, errors.New("missing required field")) + return + } + + h.logger.Debugf("Create network '%s'.", request.Name) + + response, err := h.service.Create(ctx, request) + if err != nil { + h.handleCreateError(w, request, err) + return + } + + h.logger.Debugf("Network '%s' created.", request.Name) + respond.JSON(w, http.StatusCreated, &response) +} + +func (h *handler) handleCreateError(w http.ResponseWriter, request types.NetworkCreateRequest, err error) { + var code int + + if errdefs.IsNotFound(err) { + h.logger.Errorf("Create network '%s' failed for CNI plugin '%s' not supported.", request.Name, request.Driver) + code = http.StatusNotFound + } else { + h.logger.Errorf("Create network '%s' failed: %v.", request.Name, err) + code = http.StatusInternalServerError + } + + respond.SendErrorResponse(w, code, err) +} diff --git a/pkg/api/handlers/network/create_test.go b/pkg/api/handlers/network/create_test.go new file mode 100644 index 00000000..8d779fb4 --- /dev/null +++ b/pkg/api/handlers/network/create_test.go @@ -0,0 +1,175 @@ +package network + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_network" +) + +type errorOnRead struct{} + +func (eor *errorOnRead) Read(p []byte) (int, error) { + return 0, errors.New("error on read") +} + +var _ = Describe("Network Create API Handler", func() { + const ( + path = "/networks/create" + networkName = "test-network" + networkID = "f2ce5cdfcb34238294c247a218b764347f78e55b0f61d00c6364df0ffe3a1de9" + networkDriver = "baby" + + anErrorMessageWasReturned = `{"message":\s*".*"}` + ) + + var ( + mockController *gomock.Controller + service *mocks_network.MockService + nerdctlConfig *config.Config + logger *mocks_logger.Logger + handler *handler + responseRecorder *httptest.ResponseRecorder + ) + + var parseableRequestBody = func(request types.NetworkCreateRequest) io.Reader { + json, err := json.Marshal(request) + Expect(err).ShouldNot(HaveOccurred(), "crafting request JSON") + return bytes.NewReader(json) + } + + var simpleRequest = func(opts ...types.NetworkCreateOption) (io.Reader, types.NetworkCreateRequest) { + request := *types.NewCreateNetworkRequest(networkName, opts...) + return parseableRequestBody(request), request + } + + BeforeEach(func() { + mockController = gomock.NewController(GinkgoT()) + + service = mocks_network.NewMockService(mockController) + nerdctlConfig = &config.Config{} + logger = mocks_logger.NewLogger(mockController) + handler = newHandler(service, nerdctlConfig, logger) + + responseRecorder = httptest.NewRecorder() + }) + + When("a network request occurs for a new network", func() { + It("should return a 201 Created and the network ID", func() { + reader, expected := simpleRequest() + request, err := http.NewRequest(http.MethodPost, path, reader) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + serviceResponse := types.NetworkCreateResponse{ID: networkID} + service.EXPECT().Create(gomock.Any(), expected).Return(serviceResponse, nil) + logger.EXPECT().Debugf("Create network '%s'.", networkName) + logger.EXPECT().Debugf("Network '%s' created.", networkName) + handler.create(responseRecorder, request) + + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(responseRecorder.Body.String()).Should(MatchJSON(fmt.Sprintf(`{"Id": "%s"}`, networkID))) + }) + }) + + When("a create network request occurs for an already existing network", func() { + It("should return a 201 Created, the network ID, and a warning that the network already exists", func() { + reader, expected := simpleRequest() + request, err := http.NewRequest(http.MethodPost, path, reader) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + serviceResponse := types.NetworkCreateResponse{ID: networkID, Warning: "network already exists"} + service.EXPECT().Create(gomock.Any(), expected).Return(serviceResponse, nil) + logger.EXPECT().Debugf("Create network '%s'.", networkName) + logger.EXPECT().Debugf("Network '%s' created.", networkName) + handler.create(responseRecorder, request) + + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusCreated)) + Expect(responseRecorder.Body.String()).Should(MatchJSON(fmt.Sprintf(`{"Id": "%s", "Warning": "network already exists"}`, networkID))) + }) + }) + + When("a create network request occurs with a CNI plugin that is not supported", func() { + It("should return a 404 Not Found and a message that the plugin was not found", func() { + reader, expected := simpleRequest(types.WithDriver(networkDriver)) + request, err := http.NewRequest(http.MethodPost, path, reader) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + pluginNotFoundWrapper := errdefs.NewNotFound(errors.New("unsupported cni plugin")) + service.EXPECT().Create(gomock.Any(), expected).Return(types.NetworkCreateResponse{}, pluginNotFoundWrapper) + logger.EXPECT().Debugf("Create network '%s'.", networkName) + logger.EXPECT().Errorf("Create network '%s' failed for CNI plugin '%s' not supported.", networkName, networkDriver) + handler.create(responseRecorder, request) + + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(responseRecorder.Body.String()).Should(MatchRegexp(anErrorMessageWasReturned)) + }) + }) + + When("an error occurs on network create", func() { + It("should return a 500 Internal Server Error and a message that an error occurred", func() { + reader, expected := simpleRequest() + request, err := http.NewRequest(http.MethodPost, path, reader) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + serviceErr := errors.New("internal server error") + service.EXPECT().Create(gomock.Any(), expected).Return(types.NetworkCreateResponse{}, serviceErr) + logger.EXPECT().Debugf("Create network '%s'.", networkName) + logger.EXPECT().Errorf("Create network '%s' failed: %v.", networkName, serviceErr) + handler.create(responseRecorder, request) + + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(responseRecorder.Body.String()).Should(MatchRegexp(anErrorMessageWasReturned)) + }) + }) + + When("an error occurs on request read", func() { + It("should return a 500 Internal Server Error and a message that an error occurred", func() { + request, err := http.NewRequest(http.MethodPost, path, &errorOnRead{}) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()).MinTimes(1) + handler.create(responseRecorder, request) + + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(responseRecorder.Body.String()).Should(MatchRegexp(anErrorMessageWasReturned)) + }) + }) + + When("a JSON parsing error occurs", func() { + It("should return a 400 Bad Request and a message that an error occurred", func() { + request, err := http.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{"Na}`))) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()).MinTimes(1) + handler.create(responseRecorder, request) + + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(responseRecorder.Body.String()).Should(MatchRegexp(anErrorMessageWasReturned)) + }) + }) + + When("an request occurs missing the required network name", func() { + It("should return a 500 Internal Server Error and a message that an error occurred", func() { + request, err := http.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{}`))) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + logger.EXPECT().Warn(gomock.Any()) + handler.create(responseRecorder, request) + + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(responseRecorder.Body.String()).Should(MatchRegexp(anErrorMessageWasReturned)) + }) + }) +}) diff --git a/pkg/api/handlers/network/inspect.go b/pkg/api/handlers/network/inspect.go new file mode 100644 index 00000000..0e6c9614 --- /dev/null +++ b/pkg/api/handlers/network/inspect.go @@ -0,0 +1,28 @@ +package network + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// inspect handles the api call and parses the one "id" variable +func (h *handler) inspect(w http.ResponseWriter, r *http.Request) { + nid := mux.Vars(r)["id"] + if nid == "" { + response.JSON(w, http.StatusInternalServerError, response.NewErrorFromMsg("id cannot be empty")) + return + } + resp, err := h.service.Inspect(r.Context(), nid) + if err != nil { + if errdefs.IsNotFound(err) { + response.JSON(w, http.StatusNotFound, response.NewError(err)) + return + } + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + response.JSON(w, http.StatusOK, resp) +} diff --git a/pkg/api/handlers/network/inspect_test.go b/pkg/api/handlers/network/inspect_test.go new file mode 100644 index 00000000..8ba012e4 --- /dev/null +++ b/pkg/api/handlers/network/inspect_test.go @@ -0,0 +1,90 @@ +package network + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_network" +) + +var _ = Describe("Network Inspect API ", func() { + var ( + mockCtrl *gomock.Controller + service *mocks_network.MockService + rr *httptest.ResponseRecorder + req *http.Request + handler *handler + conf *config.Config + logger *mocks_logger.Logger + mockNet *types.NetworkInspectResponse + mockNetJSON []byte + nid string + ) + BeforeEach(func() { + nid = "123" + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + // initialize mocks + service = mocks_network.NewMockService(mockCtrl) + conf = &config.Config{} + logger = mocks_logger.NewLogger(mockCtrl) + handler = newHandler(service, conf, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("/networks/%s", nid), nil) + req = mux.SetURLVars(req, map[string]string{"id": nid}) + mockNet = &types.NetworkInspectResponse{ + Name: "name", + ID: nid, + IPAM: dockercompat.IPAM{ + Config: []dockercompat.IPAMConfig{ + {Subnet: "10.5.2.0/24", Gateway: "10.5.2.1"}, + }, + }, + Labels: map[string]string{"label": "value"}, + } + var err error + mockNetJSON, err = json.Marshal(mockNet) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return a 200 when there is no error", func() { + service.EXPECT().Inspect(gomock.Any(), nid).Return(mockNet, nil) + + handler.inspect(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr.Body.String()).Should(MatchJSON(mockNetJSON)) + }) + It("should return a 404 when Inspect returns notFound", func() { + service.EXPECT().Inspect(gomock.Any(), nid).Return(nil, errdefs.NewNotFound(fmt.Errorf("not found"))) + + handler.inspect(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body.String()).Should(MatchJSON(`{"message": "not found"}`)) + }) + It("should return a 500 when Inspect returns any other error", func() { + service.EXPECT().Inspect(gomock.Any(), nid).Return(nil, fmt.Errorf("internal error")) + + handler.inspect(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body.String()).Should(MatchJSON(`{"message": "internal error"}`)) + }) + It("should return a 500 if the network ID is empty", func() { + req = mux.SetURLVars(req, map[string]string{"id": ""}) + + handler.inspect(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body.String()).Should(MatchJSON(fmt.Sprintf(`{"message": "id cannot be empty"}`))) + }) + }) +}) diff --git a/pkg/api/handlers/network/list.go b/pkg/api/handlers/network/list.go new file mode 100644 index 00000000..8c6e8b7d --- /dev/null +++ b/pkg/api/handlers/network/list.go @@ -0,0 +1,17 @@ +package network + +import ( + "net/http" + + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +// list handles the api call to list and returns a json object +func (h *handler) list(w http.ResponseWriter, r *http.Request) { + resp, err := h.service.List(r.Context()) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + response.JSON(w, http.StatusOK, resp) +} diff --git a/pkg/api/handlers/network/list_test.go b/pkg/api/handlers/network/list_test.go new file mode 100644 index 00000000..092d5f50 --- /dev/null +++ b/pkg/api/handlers/network/list_test.go @@ -0,0 +1,52 @@ +package network + +import ( + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_network" +) + +var _ = Describe("Network Inspect API ", func() { + var ( + mockCtrl *gomock.Controller + service *mocks_network.MockService + rr *httptest.ResponseRecorder + req *http.Request + handler *handler + conf *config.Config + logger *mocks_logger.Logger + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + // initialize mocks + service = mocks_network.NewMockService(mockCtrl) + conf = &config.Config{} + logger = mocks_logger.NewLogger(mockCtrl) + handler = newHandler(service, conf, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/networks", nil) + }) + Context("handler", func() { + It("should return a 200 when there is no error", func() { + service.EXPECT().List(gomock.Any()).Return(nil, nil) + + handler.list(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr.Body.String()).Should(Equal("null\n")) + }) + It("should return a 500 when there is any other error", func() { + service.EXPECT().List(gomock.Any()).Return(nil, nil) + + handler.list(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr.Body.String()).Should(Equal("null\n")) + }) + }) +}) diff --git a/pkg/api/handlers/network/network.go b/pkg/api/handlers/network/network.go new file mode 100644 index 00000000..404cc6f8 --- /dev/null +++ b/pkg/api/handlers/network/network.go @@ -0,0 +1,47 @@ +package network + +import ( + "context" + "net/http" + + "github.com/containerd/nerdctl/pkg/config" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +//go:generate mockgen --destination=../../../mocks/mocks_network/networksvc.go -package=mocks_network github.com/runfinch/finch-daemon/pkg/api/handlers/network Service +type Service interface { + Create(ctx context.Context, request types.NetworkCreateRequest) (types.NetworkCreateResponse, error) + Connect(ctx context.Context, networkId, containerId string) error + Inspect(ctx context.Context, networkId string) (*types.NetworkInspectResponse, error) + Remove(ctx context.Context, networkId string) error + List(ctx context.Context) ([]*types.NetworkInspectResponse, error) +} + +// RegisterHandlers register all the supported endpoints related to the network APIs +func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Config, logger flog.Logger) { + h := newHandler(service, conf, logger) + + r.SetPrefix("/networks") + r.HandleFunc("/create", h.create, http.MethodPost) + r.HandleFunc("/{id:.*}/connect", h.connect, http.MethodPost) + r.HandleFunc("/{id}", h.inspect, http.MethodGet) + r.HandleFunc("/{id}", h.remove, http.MethodDelete) + r.HandleFunc("/", h.list, http.MethodGet) + r.HandleFunc("", h.list, http.MethodGet) +} + +func newHandler(service Service, conf *config.Config, logger flog.Logger) *handler { + return &handler{ + service: service, + config: conf, + logger: logger, + } +} + +type handler struct { + service Service + config *config.Config + logger flog.Logger +} diff --git a/pkg/api/handlers/network/network_test.go b/pkg/api/handlers/network/network_test.go new file mode 100644 index 00000000..77c4dac7 --- /dev/null +++ b/pkg/api/handlers/network/network_test.go @@ -0,0 +1,98 @@ +package network + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_network" +) + +// TestNetworkHandler function is the entry point of network handler package's unit test using ginkgo +func TestNetworkHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Network APIs Handler") +} + +var _ = Describe("Network API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_network.MockService + rr *httptest.ResponseRecorder + req *http.Request + conf *config.Config + router *mux.Router + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + // initialize mocks + service = mocks_network.NewMockService(mockCtrl) + conf = &config.Config{} + router = mux.NewRouter() + logger = mocks_logger.NewLogger(mockCtrl) + RegisterHandlers(types.VersionedRouter{Router: router}, service, conf, logger) + rr = httptest.NewRecorder() + }) + Context("handler", func() { + When("POST /networks/create", func() { + It("should call network create handler", func() { + const ( + networkName = "test-network" + ) + + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).MinTimes(1) + service.EXPECT().Create(gomock.Any(), gomock.Any()).MaxTimes(1) + + jsonBytes := []byte(fmt.Sprintf(`{"Name": "%s"}`, networkName)) + req, err := http.NewRequest(http.MethodPost, "/networks/create", bytes.NewReader(jsonBytes)) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + router.ServeHTTP(rr, req) + }) + }) + + When("GET /networks/{id}", func() { + It("should call network inspect handler", func() { + // setup mocks + service.EXPECT().Inspect(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error from Inspect")) + req, _ = http.NewRequest(http.MethodGet, "/networks/123", nil) + // call the API to check if it returns the error generated from Inspect method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body.String()).Should(MatchJSON(`{"message": "error from Inspect"}`)) + }) + }) + It("should call the network list handler using /networks", func() { + // setup mocks + expErr := "error from List" + service.EXPECT().List(gomock.Any()).Return(nil, fmt.Errorf(expErr)) + req, _ = http.NewRequest(http.MethodGet, "/networks", nil) + // call api and check if it returns error + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body.String()).Should(MatchJSON(fmt.Sprintf(`{"message": "%s"}`, expErr))) + }) + It("should call the network list handler using /networks/", func() { + // setup mocks + expErr := "error from List" + service.EXPECT().List(gomock.Any()).Return(nil, fmt.Errorf(expErr)) + req, _ = http.NewRequest(http.MethodGet, "/networks/", nil) + // call api and check if it returns error + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body.String()).Should(MatchJSON(fmt.Sprintf(`{"message": "%s"}`, expErr))) + }) + }) +}) diff --git a/pkg/api/handlers/network/remove.go b/pkg/api/handlers/network/remove.go new file mode 100644 index 00000000..dbd45e0a --- /dev/null +++ b/pkg/api/handlers/network/remove.go @@ -0,0 +1,31 @@ +package network + +import ( + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +func (h *handler) remove(w http.ResponseWriter, r *http.Request) { + ctx := namespaces.WithNamespace(r.Context(), h.config.Namespace) + err := h.service.Remove(ctx, mux.Vars(r)["id"]) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsForbiddenError(err): + code = http.StatusForbidden + default: + code = http.StatusInternalServerError + } + logrus.Errorf("Network Remove API failed. Status code %d, Message: %s", code, err) + response.SendErrorResponse(w, code, err) + return + } + response.Status(w, http.StatusNoContent) +} diff --git a/pkg/api/handlers/network/remove_test.go b/pkg/api/handlers/network/remove_test.go new file mode 100644 index 00000000..1ab4cdc2 --- /dev/null +++ b/pkg/api/handlers/network/remove_test.go @@ -0,0 +1,67 @@ +package network + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_network" +) + +var _ = Describe("Network Remove API ", func() { + var ( + mockCtrl *gomock.Controller + service *mocks_network.MockService + rr *httptest.ResponseRecorder + req *http.Request + handler *handler + conf *config.Config + logger *mocks_logger.Logger + nid string + ) + BeforeEach(func() { + nid = "123" + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + // initialize mocks + service = mocks_network.NewMockService(mockCtrl) + conf = &config.Config{} + logger = mocks_logger.NewLogger(mockCtrl) + handler = newHandler(service, conf, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, fmt.Sprintf("/networks/%s", nid), nil) + req = mux.SetURLVars(req, map[string]string{"id": nid}) + }) + Context("handler", func() { + It("should return a 204 when there is no error", func() { + service.EXPECT().Remove(gomock.Any(), nid).Return(nil) + handler.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent)) + }) + It("should return a 404 when network is not found", func() { + service.EXPECT().Remove(gomock.Any(), nid).Return(errdefs.NewNotFound(fmt.Errorf("not found"))) + handler.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr.Body.String()).Should(MatchJSON(`{"message": "not found"}`)) + }) + It("should return a 403 when remove returns forbidden error", func() { + service.EXPECT().Remove(gomock.Any(), nid).Return(errdefs.NewForbidden(fmt.Errorf("forbidden error"))) + handler.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusForbidden)) + Expect(rr.Body.String()).Should(MatchJSON(`{"message": "forbidden error"}`)) + }) + It("should return a 500 for server errors", func() { + service.EXPECT().Remove(gomock.Any(), nid).Return(fmt.Errorf("server error")) + handler.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body.String()).Should(MatchJSON(fmt.Sprintf(`{"message": "server error"}`))) + }) + }) +}) diff --git a/pkg/api/handlers/system/auth.go b/pkg/api/handlers/system/auth.go new file mode 100644 index 00000000..1c0b084e --- /dev/null +++ b/pkg/api/handlers/system/auth.go @@ -0,0 +1,53 @@ +package system + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +type authReq struct { + Username string `json:"username"` + Password string `json:"password"` + ServerAddress string `json:"serveraddress"` + // email is deprecated: + // https://github.com/moby/moby/blob/0200623ef7b7b166c675cb14502cbc0704d3dfd4/api/types/registry/authconfig.go#L21-L24 +} + +type authResp struct { + Status string `json:"Status"` + IdentityToken string `json:"IdentityToken"` +} + +func (h *handler) auth(w http.ResponseWriter, r *http.Request) { + var req authReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.JSON(w, http.StatusBadRequest, response.NewError(err)) + return + } + if req.Username == "" { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("username is required")) + return + } + if req.Password == "" { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg("password is required")) + return + } + + token, err := h.service.Auth(r.Context(), req.Username, req.Password, req.ServerAddress) + if err != nil { + code := http.StatusInternalServerError + if errdefs.IsUnauthenticated(err) { + code = http.StatusUnauthorized + } + response.JSON(w, code, response.NewError(fmt.Errorf("failed to authenticate: %w", err))) + return + } + response.JSON(w, http.StatusOK, &authResp{ + Status: "Login Succeeded", + IdentityToken: token, + }) +} diff --git a/pkg/api/handlers/system/events.go b/pkg/api/handlers/system/events.go new file mode 100644 index 00000000..2628ce73 --- /dev/null +++ b/pkg/api/handlers/system/events.go @@ -0,0 +1,50 @@ +package system + +import ( + "encoding/json" + "fmt" + "net/http" + + eventtype "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +func (h *handler) events(w http.ResponseWriter, r *http.Request) { + filters := make(map[string][]string) + filterQuery := r.URL.Query().Get("filters") + if filterQuery != "" { + err := json.Unmarshal([]byte(filterQuery), &filters) + if err != nil { + response.JSON(w, http.StatusBadRequest, response.NewErrorFromMsg(fmt.Sprintf("invalid filter: %s", err))) + return + } + } + + eventCh, errCh := h.service.SubscribeEvents(r.Context(), filters) + + encoder := json.NewEncoder(w) + + flusher := w.(http.Flusher) + w.WriteHeader(http.StatusOK) + flusher.Flush() + + for { + var e *eventtype.Event + select { + case e = <-eventCh: + case err := <-errCh: + // logging on debug level because an error is expected when the client stops listening for events + h.logger.Debugf("received error, exiting: %s", err) + return + } + + if e != nil { + err := encoder.Encode(e) + if err != nil { + h.logger.Errorf("could not encode event to JSON: %s", err) + return + } + flusher.Flush() + } + } +} diff --git a/pkg/api/handlers/system/events_test.go b/pkg/api/handlers/system/events_test.go new file mode 100644 index 00000000..32130b32 --- /dev/null +++ b/pkg/api/handlers/system/events_test.go @@ -0,0 +1,164 @@ +package system + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "time" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_system" +) + +var _ = Describe("Events API", func() { + var ( + mockCtrl *gomock.Controller + s *mocks_system.MockService + logger *mocks_logger.Logger + h *handler + rr *httptest.ResponseRecorder + mockEvent *events.Event + mockEventJson []byte + mockEventCh chan *events.Event + mockErrCh chan error + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + s = mocks_system.NewMockService(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + c := config.Config{} + h = newHandler(s, &c, nil, logger) + rr = httptest.NewRecorder() + mockEvent = &events.Event{ + Type: "test", + Action: "test", + Actor: events.EventActor{ + Id: "123", + Attributes: map[string]string{ + "test": "test", + }, + }, + Scope: "test", + Time: 0, + TimeNano: 0, + } + mockEventJson, _ = json.Marshal(mockEvent) + }) + It("should return 200 and stream events on success", func() { + // in order to add events to the channels while the events handler is running, we need to either run the handler + // or the channel publisher in a separate goroutine as the events handler will block until it returns. because all + // of the assertions are in the same thread as the channel publisher, it becomes easier to run the handler in a goroutine. + // however, this can cause a problem where the handler doesn't finish executing before the test function does. this + // WaitGroup allows us to signal the test function that the handler has finished executing, and we can block in the + // main thread until the handler returns. + var waitGroup sync.WaitGroup + + mockEventCh = make(chan *events.Event) + mockErrCh = make(chan error) + + req, _ := http.NewRequest(http.MethodGet, "/events", nil) + + s.EXPECT().SubscribeEvents(req.Context(), map[string][]string{}).Return(mockEventCh, mockErrCh) + logger.EXPECT().Debugf("received error, exiting: %s", gomock.Any()) + + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + + h.events(rr, req) + }() + + mockEventCh <- mockEvent + time.Sleep(250 * time.Millisecond) + + // repeat to test that streaming is working + mockEventCh <- mockEvent + time.Sleep(250 * time.Millisecond) + + // I didn't put these in AfterEach() because they will cause a logger.Debugf in some cases but not all. this means + // that if we EXPECT() the Debugf call in the spec itself, it will fail, and we can't EXPECT() it in AfterEach(). + close(mockEventCh) + close(mockErrCh) + + waitGroup.Wait() + + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + + line, err := rr.Body.ReadBytes('\n') + Expect(err).Should(BeNil()) + Expect(line).Should(MatchJSON(mockEventJson)) + + line, err = rr.Body.ReadBytes('\n') + Expect(err).Should(BeNil()) + Expect(line).Should(MatchJSON(mockEventJson)) + }) + It("should return 400 if filters are not in the correct format", func() { + req, _ := http.NewRequest(http.MethodGet, "/events?filters=bad", nil) + req = mux.SetURLVars(req, map[string]string{"filters": "bad"}) + + h.events(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body).Should(MatchJSON(`{"message": "invalid filter: invalid character 'b' looking for beginning of value"}`)) + }) + It("should forward filters to the service", func() { + var waitGroup sync.WaitGroup + + mockEventCh = make(chan *events.Event) + mockErrCh = make(chan error) + + req, _ := http.NewRequest(http.MethodGet, `/events?filters={"test": ["test"]}`, nil) + req = mux.SetURLVars(req, map[string]string{"filters": `{"test": ["test"]}`}) + + s.EXPECT().SubscribeEvents(req.Context(), map[string][]string{"test": {"test"}}).Return(mockEventCh, mockErrCh) + logger.EXPECT().Debugf("received error, exiting: %s", gomock.Any()) + + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + + h.events(rr, req) + }() + + close(mockEventCh) + close(mockErrCh) + + waitGroup.Wait() + + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should stop streaming if an error is received", func() { + var waitGroup sync.WaitGroup + + mockEventCh = make(chan *events.Event) + mockErrCh = make(chan error) + + req, _ := http.NewRequest(http.MethodGet, "/events", nil) + + s.EXPECT().SubscribeEvents(req.Context(), map[string][]string{}).Return(mockEventCh, mockErrCh) + logger.EXPECT().Debugf("received error, exiting: %s", gomock.Any()) + + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + + h.events(rr, req) + }() + + mockErrCh <- fmt.Errorf("mock error") + + waitGroup.Wait() + + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + + close(mockEventCh) + close(mockErrCh) + }) +}) diff --git a/pkg/api/handlers/system/info.go b/pkg/api/handlers/system/info.go new file mode 100644 index 00000000..01d7c37c --- /dev/null +++ b/pkg/api/handlers/system/info.go @@ -0,0 +1,17 @@ +package system + +import ( + "net/http" + + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +func (h *handler) info(w http.ResponseWriter, r *http.Request) { + infoCompat, err := h.service.GetInfo(r.Context(), h.Config) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + + response.JSON(w, http.StatusOK, infoCompat) +} diff --git a/pkg/api/handlers/system/info_test.go b/pkg/api/handlers/system/info_test.go new file mode 100644 index 00000000..beac6d6c --- /dev/null +++ b/pkg/api/handlers/system/info_test.go @@ -0,0 +1,63 @@ +package system + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_system" +) + +var _ = Describe("System Info API Handler", func() { + const ( + path = "/info" + ) + + var ( + mockController *gomock.Controller + service *mocks_system.MockService + handler *handler + responseRecorder *httptest.ResponseRecorder + ) + + BeforeEach(func() { + mockController = gomock.NewController(GinkgoT()) + service = mocks_system.NewMockService(mockController) + cfg := config.Config{} + handler = newHandler(service, &cfg, nil, nil) + responseRecorder = httptest.NewRecorder() + }) + + When("getting the system info", func() { + It("should return 200 Ok and the system info", func() { + expected := &dockercompat.Info{} + service.EXPECT().GetInfo(gomock.Any(), gomock.Any()).Return(expected, nil) + + httpRequest, err := http.NewRequest(http.MethodGet, path, nil) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + handler.info(responseRecorder, httpRequest) + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusOK)) + }) + }) + + When("getting the system info causes an error", func() { + It("should return 500 Internal Server Error and a message", func() { + expected := errors.New("get system info error") + service.EXPECT().GetInfo(gomock.Any(), gomock.Any()).Return(nil, expected) + + httpRequest, err := http.NewRequest(http.MethodGet, path, nil) + Expect(err).ShouldNot(HaveOccurred(), "crafting HTTP request") + + handler.info(responseRecorder, httpRequest) + Expect(responseRecorder).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(responseRecorder.Body.String()).Should(MatchJSON(fmt.Sprintf(`{"message": "%v"}`, expected))) + }) + }) +}) diff --git a/pkg/api/handlers/system/ping.go b/pkg/api/handlers/system/ping.go new file mode 100644 index 00000000..cb112804 --- /dev/null +++ b/pkg/api/handlers/system/ping.go @@ -0,0 +1,12 @@ +package system + +import ( + "net/http" + + "github.com/runfinch/finch-daemon/pkg/version" +) + +// ping is a simple API endpoint to verify the server's accessibility +func (h *handler) ping(w http.ResponseWriter, r *http.Request) { + w.Header().Set("API-Version", version.DefaultApiVersion) +} diff --git a/pkg/api/handlers/system/ping_test.go b/pkg/api/handlers/system/ping_test.go new file mode 100644 index 00000000..64e6d9b9 --- /dev/null +++ b/pkg/api/handlers/system/ping_test.go @@ -0,0 +1,31 @@ +package system + +import ( + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/version" +) + +// Unit tests for the ping api +var _ = Describe("Ping", func() { + var ( + h *handler + rr *httptest.ResponseRecorder + ) + + BeforeEach(func() { + c := config.Config{} + h = newHandler(nil, &c, nil, nil) + rr = httptest.NewRecorder() + }) + + It("should return with an OK status with the API-Version set to the current version", func() { + h.ping(rr, nil) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr.Header().Values("API-Version")[0]).Should(Equal(version.DefaultApiVersion)) + }) +}) diff --git a/pkg/api/handlers/system/system.go b/pkg/api/handlers/system/system.go new file mode 100644 index 00000000..dd8a9b97 --- /dev/null +++ b/pkg/api/handlers/system/system.go @@ -0,0 +1,56 @@ +// Package system contains functions and structures related to system level APIs +package system + +import ( + "context" + "net/http" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + eventtype "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +// Service defines the interface for functions that provide supplementary information to the handlers +// +//go:generate mockgen --destination=../../../mocks/mocks_system/systemsvc.go -package=mocks_system github.com/runfinch/finch-daemon/pkg/api/handlers/system Service +type Service interface { + Auth(ctx context.Context, username, password, serverAddr string) (string, error) + SubscribeEvents(ctx context.Context, filters map[string][]string) (<-chan *eventtype.Event, <-chan error) + GetInfo(ctx context.Context, config *config.Config) (*dockercompat.Info, error) + GetVersion(ctx context.Context) (*types.VersionInfo, error) +} + +// RegisterHandlers sets up the handlers and assigns the handlers to the router. Both r and versionedR are used +// as `GET /version` is called by the docker python client without the API version number +func RegisterHandlers( + r types.VersionedRouter, + service Service, + conf *config.Config, + ncVersionSvc backend.NerdctlSystemSvc, + logger flog.Logger) { + h := newHandler(service, conf, ncVersionSvc, logger) + r.HandleFunc("/info", h.info, http.MethodGet) + r.HandleFunc("/version", h.version, http.MethodGet) + r.HandleFunc("/_ping", h.ping, http.MethodHead, http.MethodGet) + r.HandleFunc("/auth", h.auth, http.MethodPost) + r.HandleFunc("/events", h.events, http.MethodGet) +} + +func newHandler(service Service, conf *config.Config, ncSystemSvc backend.NerdctlSystemSvc, logger flog.Logger) *handler { + return &handler{ + service: service, + Config: conf, + ncSystemSvc: ncSystemSvc, + logger: logger, + } +} + +type handler struct { + service Service + Config *config.Config + ncSystemSvc backend.NerdctlSystemSvc + logger flog.Logger +} diff --git a/pkg/api/handlers/system/system_test.go b/pkg/api/handlers/system/system_test.go new file mode 100644 index 00000000..60ddc308 --- /dev/null +++ b/pkg/api/handlers/system/system_test.go @@ -0,0 +1,14 @@ +package system + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// TestSystem is the entry point for unit tests in the system package +func TestSystem(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - System APIs Handler") +} diff --git a/pkg/api/handlers/system/version.go b/pkg/api/handlers/system/version.go new file mode 100644 index 00000000..4bf1ee4f --- /dev/null +++ b/pkg/api/handlers/system/version.go @@ -0,0 +1,21 @@ +package system + +import ( + "net/http" + + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +// version is the basic form of GET `/version`, this allows docker.from_env() to work +// and allows testing with the docker python SDK directly +// +// TODO: Add in additional server information +func (h *handler) version(w http.ResponseWriter, r *http.Request) { + vInfo, err := h.service.GetVersion(r.Context()) + if err != nil { + h.logger.Warnf("unable to retrieve server component versions: %v", err) + response.SendErrorResponse(w, http.StatusInternalServerError, err) + return + } + response.JSON(w, http.StatusOK, vInfo) +} diff --git a/pkg/api/handlers/system/version_test.go b/pkg/api/handlers/system/version_test.go new file mode 100644 index 00000000..2c767d5a --- /dev/null +++ b/pkg/api/handlers/system/version_test.go @@ -0,0 +1,87 @@ +package system + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_system" +) + +var _ = Describe("Version API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_system.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_system.NewMockService(mockCtrl) + ncClient := mocks_backend.NewMockNerdctlSystemSvc(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, ncClient, logger) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/version", nil) + + }) + Context("handler", func() { + It("should return 200 as success response", func() { + // service mock returns nil to mimic handler generated the version info successfully. + expectedVersion := types.VersionInfo{ + Platform: struct { + Name string + }{}, + Version: "0.0.1", + ApiVersion: "1.43", + MinAPIVersion: "1.35", + GitCommit: "abcd", + Os: "linux", + Arch: "x86", + KernelVersion: "kernel-123", + Experimental: true, + Components: []types.ComponentVersion{ + { + Name: "containerd", + Version: "v1.7.1", + Details: map[string]string{ + "GitCommit": "1677a17964311325ed1c31e2c0a3589ce6d5c30d", + }}, + }, + } + service.EXPECT().GetVersion(gomock.Any()).Return(&expectedVersion, nil) + + //handler should return success message with 200 status code. + h.version(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + jd := json.NewDecoder(rr.Body) + var v types.VersionInfo + err := jd.Decode(&v) + Expect(err).ShouldNot(HaveOccurred()) + Expect(v).Should(Equal(expectedVersion)) + }) + + It("should return 500 internal error response", func() { + // service mock returns not found error to mimic version info could not generate due internal error. + logger.EXPECT().Warnf(gomock.Any(), gomock.Any()).Return().AnyTimes() + service.EXPECT().GetVersion(gomock.Any()).Return(nil, fmt.Errorf("some error")) + + //handler should return 500 status code with an error msg. + h.version(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "some error"}`)) + }) + }) +}) diff --git a/pkg/api/handlers/volume/create.go b/pkg/api/handlers/volume/create.go new file mode 100644 index 00000000..5b69b380 --- /dev/null +++ b/pkg/api/handlers/volume/create.go @@ -0,0 +1,51 @@ +package volume + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +type CreateVolumeRequest struct { + Name string + Driver string + DriverOpts map[string]string + Labels map[string]string +} + +func (h *handler) create(w http.ResponseWriter, r *http.Request) { + // https://docs.docker.com/engine/api/v1.41/#tag/Volume/operation/VolumeCreate + var requestJson CreateVolumeRequest + err := json.NewDecoder(r.Body).Decode(&requestJson) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + + if requestJson.Driver != "local" && requestJson.Driver != "" { + fmt.Printf("driver is = %s\n", requestJson.Driver) + h.logger.Warnf("Driver is not currently supported, ignoring") + } + + if len(requestJson.DriverOpts) != 0 { + h.logger.Warnf("Driver Options is not currently supported, ignoring\n") + } + + labelSlice := labelMapToSlice(requestJson.Labels) + vol, err := h.service.Create(r.Context(), requestJson.Name, labelSlice) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + response.JSON(w, http.StatusOK, vol) +} + +func labelMapToSlice(inputMap map[string]string) []string { + labelSlice := []string{} + for key, val := range inputMap { + labelSlice = append(labelSlice, key+"="+val) + } + return labelSlice +} diff --git a/pkg/api/handlers/volume/create_test.go b/pkg/api/handlers/volume/create_test.go new file mode 100644 index 00000000..9ef8a07d --- /dev/null +++ b/pkg/api/handlers/volume/create_test.go @@ -0,0 +1,77 @@ +package volume + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_volume" +) + +var _ = Describe("Create Volume API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_volume.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_volume.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + var err error + Expect(err).Should(BeNil()) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return volume list object and 200 status code upon success", func() { + // setup mocks + response := &native.Volume{Name: "NewVolume"} + service.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(response, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + reqJson := []byte(`{"Name": "NewVolume"}`) + req, _ = http.NewRequest(http.MethodPost, "/volumes/create", bytes.NewBuffer(reqJson)) + // call the API to check if it returns the error generated from the list method + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr.Body).Should(MatchJSON(`{"Name": "NewVolume", "Mountpoint": ""}`)) + }) + It("should return 500 status code if volume name is missing", func() { + // setup mocks + service.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("error from create api")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + reqJson := []byte(`{}`) + req, _ = http.NewRequest(http.MethodPost, "/volumes/create", bytes.NewBuffer(reqJson)) + // call the API to check if it returns the error generated from the list method + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from create api"}`)) + }) + It("should return 500 status code if service returns an error message", func() { + // setup mocks + service.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("error from create api")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + reqJson := []byte(`{"Name": "NewVolume"}`) + req, _ = http.NewRequest(http.MethodPost, "/volumes/create", bytes.NewBuffer(reqJson)) + // call the API to check if it returns the error generated from the list method + h.create(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from create api"}`)) + }) + }) + +}) diff --git a/pkg/api/handlers/volume/inpsect.go b/pkg/api/handlers/volume/inpsect.go new file mode 100644 index 00000000..2a1a8735 --- /dev/null +++ b/pkg/api/handlers/volume/inpsect.go @@ -0,0 +1,24 @@ +package volume + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// inspect function returns the details of a volume if exists or else return not found error +func (h *handler) inspect(w http.ResponseWriter, r *http.Request) { + vol := mux.Vars(r)["name"] + resp, err := h.service.Inspect(vol) + if err != nil { + code := http.StatusInternalServerError + if errdefs.IsNotFound(err) { + code = http.StatusNotFound + } + response.JSON(w, code, response.NewError(err)) + return + } + response.JSON(w, http.StatusOK, resp) +} diff --git a/pkg/api/handlers/volume/inpsect_test.go b/pkg/api/handlers/volume/inpsect_test.go new file mode 100644 index 00000000..ab3d9734 --- /dev/null +++ b/pkg/api/handlers/volume/inpsect_test.go @@ -0,0 +1,78 @@ +package volume + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_volume" +) + +var _ = Describe("Volume Inspect API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_volume.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + volName string + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_volume.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + var err error + volName = "test-volume" + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/volumes/%s", volName), nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"name": volName}) + }) + Context("handler", func() { + It("should successfully return volume details", func() { + resp := + native.Volume{ + Name: "test-volume", + Mountpoint: "/path/to/test-volume", + Labels: nil, + Size: 100, + } + service.EXPECT().Inspect(volName).Return(&resp, nil) + + // handler should return response object with 200 status code + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"Name": "test-volume", "Mountpoint": "/path/to/test-volume", "Size": 100}`)) + }) + It("should return 404 status code if service returns not found error", func() { + service.EXPECT().Inspect(volName).Return(nil, errdefs.NewNotFound(fmt.Errorf("not found"))) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + // handler should return not found error msg + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "not found"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + It("should return 500 status code if service returns an error message", func() { + service.EXPECT().Inspect(volName).Return(nil, fmt.Errorf("some error")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + // handler should return error message + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "some error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + }) +}) diff --git a/pkg/api/handlers/volume/list.go b/pkg/api/handlers/volume/list.go new file mode 100644 index 00000000..983e4466 --- /dev/null +++ b/pkg/api/handlers/volume/list.go @@ -0,0 +1,78 @@ +package volume + +import ( + "encoding/json" + "net/http" + + "github.com/runfinch/finch-daemon/pkg/api/response" +) + +func (h *handler) list(w http.ResponseWriter, r *http.Request) { + // filters are JSON encoded value of the filters as a map[string][]string + // https://docs.docker.com/engine/api/v1.40/#tag/Volume/operation/VolumeList + rawJSONFilters := r.URL.Query().Get("filters") + + filtersSlice, err := rawJSONFiltersToSlice(rawJSONFilters) + if err != nil && rawJSONFilters != "" { + h.logger.Errorf("could not convert filters to JSON: %v", err) + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + } + + resp, err := h.service.List(r.Context(), filtersSlice) + if err != nil { + response.JSON(w, http.StatusInternalServerError, response.NewError(err)) + return + } + response.JSON(w, http.StatusOK, resp) +} + +// filterJSONRequest represents the raw JSON string passed as URL param to +// the volumes list API. + +// An example encoded request for {"name": "test"} looks like: +// +// GET /v1.41/volumes?filters=%7B%22name%22%3A%5B%22test%22%5D%7D' +type filterJSONRequest struct { + Name []string `json:"name"` + Driver []string `json:"driver"` + Labels []string `json:"labels"` + Dangling []string `json:"dangling"` +} + +// rawJSONFiltersToSlice converts a raw JSON URL object to a slice +// of individual filter expressions. +// e.g.: +// +// {"name":["test", "bar"], "driver":["foo"]} +// +// becomes the string slice +// +// {"name=test", "name=bar", "driver=foo"} +func rawJSONFiltersToSlice(rawJSONFilters string) ([]string, error) { + filtersJSON := filterJSONRequest{} + err := json.Unmarshal([]byte(rawJSONFilters), &filtersJSON) + if err != nil { + return nil, err + } + + filters := []string{} + danglingExprs := createFilterExpressions("dangling", filtersJSON.Dangling) + driverExprs := createFilterExpressions("driver", filtersJSON.Driver) + labelExprs := createFilterExpressions("label", filtersJSON.Labels) + nameExprs := createFilterExpressions("name", filtersJSON.Name) + + filters = append(filters, danglingExprs...) + filters = append(filters, driverExprs...) + filters = append(filters, labelExprs...) + filters = append(filters, nameExprs...) + + return filters, nil +} + +func createFilterExpressions(key string, vals []string) []string { + expressions := []string{} + for _, val := range vals { + expressions = append(expressions, key+"="+val) + } + return expressions +} diff --git a/pkg/api/handlers/volume/list_test.go b/pkg/api/handlers/volume/list_test.go new file mode 100644 index 00000000..210166b0 --- /dev/null +++ b/pkg/api/handlers/volume/list_test.go @@ -0,0 +1,90 @@ +package volume + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_volume" +) + +var _ = Describe("Volume List API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_volume.MockService + h *handler + rr *httptest.ResponseRecorder + name string + req *http.Request + filterString string + filterReq *http.Request + resp types.VolumesListResponse + respJSON []byte + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_volume.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + name = "test-volume" + var err error + req, err = http.NewRequest(http.MethodGet, "/volumes", nil) + Expect(err).Should(BeNil()) + filterString = url.QueryEscape(fmt.Sprintf(`{"name":["%s"]}`, name)) + filterReq, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/volumes?filters=%s", filterString), nil) + Expect(err).Should(BeNil()) + resp = types.VolumesListResponse{ + Volumes: []native.Volume{ + native.Volume{ + Name: name, + Mountpoint: "/path/to/test-volume", + Labels: nil, + Size: 100, + }, + }, + } + respJSON, err = json.Marshal(resp) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return volume list object and 200 status code upon success", func() { + service.EXPECT().List(gomock.Any(), gomock.Any()).Return(&resp, nil) + + // handler should return response object with 200 status code + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(respJSON)) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return volume list object and 200 status code upon success with filters", func() { + service.EXPECT().List(gomock.Any(), gomock.Any()).Return(&resp, nil) + + // handler should return response object with 200 status code + h.list(rr, filterReq) + Expect(rr.Body).Should(MatchJSON(respJSON)) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 500 status code if service returns an error message", func() { + service.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")) + + // handler should return error message + h.list(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + }) + +}) diff --git a/pkg/api/handlers/volume/remove.go b/pkg/api/handlers/volume/remove.go new file mode 100644 index 00000000..63734155 --- /dev/null +++ b/pkg/api/handlers/volume/remove.go @@ -0,0 +1,35 @@ +package volume + +import ( + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// remove handler deletes a volume if exists and not being used by any container +func (h *handler) remove(w http.ResponseWriter, r *http.Request) { + volName := mux.Vars(r)["name"] + force, err := strconv.ParseBool(r.URL.Query().Get("force")) + if err != nil { + force = false + } + + err = h.service.Remove(r.Context(), volName, force) + if err != nil { + var code int + switch { + case errdefs.IsNotFound(err): + code = http.StatusNotFound + case errdefs.IsConflict(err): + code = http.StatusConflict + default: + code = http.StatusInternalServerError + } + response.SendErrorResponse(w, code, err) + return + } + response.Status(w, http.StatusNoContent) +} diff --git a/pkg/api/handlers/volume/remove_test.go b/pkg/api/handlers/volume/remove_test.go new file mode 100644 index 00000000..043fc3af --- /dev/null +++ b/pkg/api/handlers/volume/remove_test.go @@ -0,0 +1,77 @@ +package volume + +import ( + "fmt" + "net/http" + "net/http/httptest" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_volume" +) + +var _ = Describe("Volume Remove API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_volume.MockService + h *handler + rr *httptest.ResponseRecorder + req *http.Request + ) + BeforeEach(func() { + //initialize the mocks. + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_volume.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + var err error + req, err = http.NewRequest(http.MethodDelete, "/volumes/test-volume", nil) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return 204 status code upon success", func() { + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + // handler should return response object with 200 status code + h.remove(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusNoContent)) + }) + It("should return 500 status code if service returns an error message", func() { + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("error")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + // handler should return error message + h.remove(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + It("should return 404 status code if service returns an not found error", func() { + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()). + Return(errdefs.NewNotFound(fmt.Errorf("not found"))) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + // handler should return error message + h.remove(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "not found"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + It("should return 409 status code if service returns volume is in use error", func() { + service.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()). + Return(errdefs.NewConflict(fmt.Errorf("in use"))) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + // handler should return error message + h.remove(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "in use"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusConflict)) + }) + }) +}) diff --git a/pkg/api/handlers/volume/volume.go b/pkg/api/handlers/volume/volume.go new file mode 100644 index 00000000..70110e8c --- /dev/null +++ b/pkg/api/handlers/volume/volume.go @@ -0,0 +1,45 @@ +// package volume defines the API service and handlers for the volumes API. +package volume + +import ( + "context" + "net/http" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +//go:generate mockgen --destination=../../../mocks/mocks_volume/volumesvc.go -package=mocks_volume github.com/runfinch/finch-daemon/pkg/api/handlers/volume Service +type Service interface { + Create(ctx context.Context, name string, labels []string) (*native.Volume, error) + List(ctx context.Context, filters []string) (*types.VolumesListResponse, error) + Remove(ctx context.Context, volName string, force bool) error + Inspect(volName string) (*native.Volume, error) +} + +func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Config, logger flog.Logger) { + h := newHandler(service, conf, logger) + + r.SetPrefix("/volumes") + r.HandleFunc("", h.list, http.MethodGet) + r.HandleFunc("/{name:.*}", h.inspect, http.MethodGet) + r.HandleFunc("/{name:.*}", h.remove, http.MethodDelete) + r.HandleFunc("/create", h.create, http.MethodPost) +} + +func newHandler(service Service, conf *config.Config, logger flog.Logger) *handler { + return &handler{ + service: service, + Config: conf, + logger: logger, + } +} + +type handler struct { + service Service + Config *config.Config + logger flog.Logger +} diff --git a/pkg/api/handlers/volume/volume_test.go b/pkg/api/handlers/volume/volume_test.go new file mode 100644 index 00000000..79e1a782 --- /dev/null +++ b/pkg/api/handlers/volume/volume_test.go @@ -0,0 +1,72 @@ +package volume + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_volume" +) + +// TestVolumesHandler function is the entry point of volumes handler package's unit test using ginkgo +func TestVolumesHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Volumes APIs Handler") +} + +// Unit tests related to check RegisterHandlers() has configured the endpoint properly for volume related APIs +var _ = Describe("Volumes API ", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_volume.MockService + rr *httptest.ResponseRecorder + req *http.Request + conf config.Config + router *mux.Router + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_volume.NewMockService(mockCtrl) + router = mux.NewRouter() + RegisterHandlers(types.VersionedRouter{Router: router}, service, &conf, logger) + rr = httptest.NewRecorder() + + }) + Context("handler", func() { + It("should call volumes list method", func() { + // setup mocks + service.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, errors.New("error from list api")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + req, _ = http.NewRequest(http.MethodGet, "/volumes", nil) + // call the API to check if it returns the error generated from the list method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from list api"}`)) + }) + It("should call volumes create method", func() { + // setup mocks + service.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("error from create api")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + reqJson := []byte(`{"Name": "NewVolume"}`) + req, _ = http.NewRequest(http.MethodPost, "/volumes/create", bytes.NewBuffer(reqJson)) + // call the API to check if it returns the error + // generated from the create method + router.ServeHTTP(rr, req) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + Expect(rr.Body).Should(MatchJSON(`{"message": "error from create api"}`)) + }) + }) +}) diff --git a/pkg/api/response/error.go b/pkg/api/response/error.go new file mode 100644 index 00000000..95af3373 --- /dev/null +++ b/pkg/api/response/error.go @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package response + +import ( + "encoding/json" + "net/http" + + "github.com/docker/docker/pkg/jsonmessage" +) + +// Error implements the error structure used in Docker Engine API. +type Error struct { + Message string `json:"message"` +} + +func NewError(err error) *Error { + return &Error{ + Message: err.Error(), + } +} + +func NewErrorFromMsg(msg string) *Error { + return &Error{ + Message: msg, + } +} + +// SendErrorResponse sends a status code and a json-formatted error message (if any) +func SendErrorResponse(w http.ResponseWriter, code int, err error) { + if code == http.StatusNotModified { + // only set the status code as no content and not modified does not have response body + // for more details see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304 + w.WriteHeader(code) + } else { + JSON(w, code, NewError(err)) + } +} + +// SendErrorAsInStreamResponse sends a status code and a json-formatted error message in stream response +func SendErrorAsInStreamResponse(w http.ResponseWriter, code int, err error) error { + jsonErr := jsonmessage.JSONError{ + Code: code, + Message: err.Error(), + } + jw := json.NewEncoder(w) + return jw.Encode(StreamResponse{ + Error: &jsonErr, + ErrorMessage: err.Error(), + }) + +} diff --git a/pkg/api/response/json.go b/pkg/api/response/json.go new file mode 100644 index 00000000..ea99c441 --- /dev/null +++ b/pkg/api/response/json.go @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package response + +import ( + "encoding/json" + "log" + "net/http" +) + +// Status writes the supplied HTTP status code to response header +func Status(w http.ResponseWriter, code int) { + w.WriteHeader(code) +} + +// JSON writes data as JSON object(s) to response with the supplied HTTP status code. +func JSON(w http.ResponseWriter, code int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if err := json.NewEncoder(w).Encode(data); err != nil { + log.Printf("failed to JSON-encode response: %v", err) + } +} diff --git a/pkg/api/response/streamwriter.go b/pkg/api/response/streamwriter.go new file mode 100644 index 00000000..c5d4235f --- /dev/null +++ b/pkg/api/response/streamwriter.go @@ -0,0 +1,173 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package response + +import ( + "encoding/json" + "net/http" + "strings" + "sync" + + "github.com/docker/docker/pkg/jsonmessage" +) + +// StreamResponse holds the stream and error messages, if any, for events and status updates. +// +// From https://github.com/moby/moby/blob/v24.0.4/pkg/jsonmessage/jsonmessage.go#L145-L158 +type StreamResponse struct { + Stream string `json:"stream,omitempty"` + // TODO: Status string `json:"status,omitempty"` + // TODO: Progress *JSONProgress `json:"progressDetail,omitempty"` + // TODO: ProgressMessage string `json:"progress,omitempty"` // deprecated + // TODO: ID string `json:"id,omitempty"` + // TODO: From string `json:"from,omitempty"` + // TODO: Time int64 `json:"time,omitempty"` + // TODO: TimeNano int64 `json:"timeNano,omitempty"` + Error *jsonmessage.JSONError `json:"errorDetail,omitempty"` + ErrorMessage string `json:"error,omitempty"` // deprecated + // Aux contains out-of-band data, such as digests for push signing and image id after building. + Aux *json.RawMessage `json:"aux,omitempty"` +} + +// StreamWriter allows to write stdout, stderr output as json steaming data in http response. +// This struct implements the Write() function defined in Writer interface which allows StreamWriter +// to capture the output of stdout and stderr and send it to json stream as per docker api spec. +type StreamWriter struct { + responseWriter http.ResponseWriter + jsonEncoder *json.Encoder + flusher http.Flusher + initializer sync.Once +} + +// NewStreamWriter creates a new StreamWriter +func NewStreamWriter(w http.ResponseWriter) *StreamWriter { + sw := &StreamWriter{ + responseWriter: w, + jsonEncoder: json.NewEncoder(w), + } + // check if the writer implements http.Flusher interface and assign it to flusher + if flusher, ok := w.(http.Flusher); ok && flusher != nil { + sw.flusher = flusher + } + return sw +} + +// Write function is implementation of Writer interface. This function converts the stdout and stderr output into +// json stream and send it though http response. +func (sw *StreamWriter) Write(b []byte) (n int, err error) { + // set response header and status code only once + sw.initializer.Do(func() { + sw.responseWriter.Header().Set("Content-Type", "application/json") + sw.responseWriter.WriteHeader(http.StatusOK) + }) + + err = sw.jsonEncoder.Encode(StreamResponse{Stream: string(b)}) + if err != nil { + return 0, err + } + if sw.flusher != nil { + // flush after each write so the client can receive status + // updates as they happen + sw.flusher.Flush() + } + return len(b), nil +} + +// WriteError sends a Docker-compatible error message and status code as a +// JSON stream to the http responseWriter. +// If header is not already set, it will set the header and send error message as a response.Error. +func (sw *StreamWriter) WriteError(code int, err error) error { + // set response header and status code only once + firstMessage := false + sw.initializer.Do(func() { + SendErrorResponse(sw.responseWriter, code, err) + firstMessage = true + }) + + if firstMessage { + return nil + } + + jsonErr := jsonmessage.JSONError{ + Code: code, + Message: err.Error(), + } + return sw.jsonEncoder.Encode(StreamResponse{ + Error: &jsonErr, + ErrorMessage: err.Error(), + }) +} + +// WriteAux sends raw data as a Docker-compatible auxiliary response, +// such as digests for pushed image or image id after building. +func (sw *StreamWriter) WriteAux(data []byte) error { + // set response header and status code only once + sw.initializer.Do(func() { + sw.responseWriter.Header().Set("Content-Type", "application/json") + sw.responseWriter.WriteHeader(http.StatusOK) + }) + + aux := json.RawMessage(data) + err := sw.jsonEncoder.Encode(StreamResponse{Aux: &aux}) + if err != nil { + return err + } + if sw.flusher != nil { + sw.flusher.Flush() + } + + return nil +} + +type pullJobWriter struct { + StreamWriter + resolved bool + mx sync.Mutex +} + +// PullJobWriter is an extension of StreamWriter that sends image pull status updates from +// nerdctl to http response as a JSON stream. It ensures that the image is resolved before +// writing anything to the response body, so that any resolver errors can be sent to the client +// directly with an appropriate status code. +func NewPullJobWriter(w http.ResponseWriter) *pullJobWriter { + pw := &pullJobWriter{ + StreamWriter: StreamWriter{ + responseWriter: w, + jsonEncoder: json.NewEncoder(w), + }, + resolved: false, + } + // check if the writer implements http.Flusher interface and assign it to flusher + if flusher, ok := w.(http.Flusher); ok && flusher != nil { + pw.flusher = flusher + } + return pw +} + +// Write function is implementation of Writer interface, similar to that of StreamWriter. +// However, until the image is resolved, nothing is written to the response body and header remains unset. +func (pw *pullJobWriter) Write(b []byte) (n int, err error) { + if !pw.IsResolved() { + str := string(b) + if strings.Contains(str, "resolved") { + pw.Resolve() + } else { + return 0, nil + } + } + + return pw.StreamWriter.Write(b) +} + +func (pw *pullJobWriter) IsResolved() bool { + pw.mx.Lock() + defer pw.mx.Unlock() + return pw.resolved +} + +func (pw *pullJobWriter) Resolve() { + pw.mx.Lock() + defer pw.mx.Unlock() + pw.resolved = true +} diff --git a/pkg/api/response/streamwriter_test.go b/pkg/api/response/streamwriter_test.go new file mode 100644 index 00000000..ad42b8bb --- /dev/null +++ b/pkg/api/response/streamwriter_test.go @@ -0,0 +1,286 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package response + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/docker/docker/pkg/jsonmessage" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_http" +) + +// TestAPIResponse function is the entry point of api response package's unit test using ginkgo +func TestAPIResponse(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - API Response Utils") +} + +var _ = Describe("API Response ", func() { + var ( + mockCtrl *gomock.Controller + respWriter *mocks_http.MockResponseWriter + respHeader http.Header + msg1, msg2, msg3, auxMsg []byte + resp1, resp2, resp3, auxResp []byte + errMsg error + errResp []byte + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + respWriter = mocks_http.NewMockResponseWriter(mockCtrl) + respHeader = http.Header{} + + // message strings + msg1 = []byte("stream message 1") + msg2 = []byte("hello world") + msg3 = []byte("last message") + errMsg = fmt.Errorf("got error") + auxMsg = []byte(`{"message":"aux data"}`) + + // stream responses + resp1 = []byte(fmt.Sprintf(`{"stream":"%s"}`+"\n", msg1)) + resp2 = []byte(fmt.Sprintf(`{"stream":"%s"}`+"\n", msg2)) + resp3 = []byte(fmt.Sprintf(`{"stream":"%s"}`+"\n", msg3)) + auxResp = []byte(fmt.Sprintf(`{"aux":%s}`+"\n", auxMsg)) + + // error response + var err error + errResp, err = json.Marshal(StreamResponse{ + Error: &jsonmessage.JSONError{ + Code: http.StatusInternalServerError, + Message: errMsg.Error(), + }, + ErrorMessage: errMsg.Error(), + }) + Expect(err).Should(BeNil()) + errResp = append(errResp, byte('\n')) + }) + + Context("StreamWriter", func() { + It("should write a message as a JSON stream object", func() { + // expected calls to response writer + respWriter.EXPECT().Header().Return(respHeader) + respWriter.EXPECT().WriteHeader(http.StatusOK) + respWriter.EXPECT().Write(resp1).Return(len(msg1), nil) + + // streamwriter should successfully write the message and set header + sw := NewStreamWriter(respWriter) + n, err := sw.Write(msg1) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg1))) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + }) + It("should write multiple messages as a JSON stream and set header once", func() { + // expected calls to response writer + respWriter.EXPECT().Header().Return(respHeader) + respWriter.EXPECT().WriteHeader(http.StatusOK) + respWriter.EXPECT().Write(resp1).Return(len(msg1), nil) + respWriter.EXPECT().Write(resp2).Return(len(msg2), nil) + respWriter.EXPECT().Write(resp3).Return(len(msg3), nil) + + // streamwriter should successfully write the messages and set header + sw := NewStreamWriter(respWriter) + n, err := sw.Write(msg1) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg1))) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + + n, err = sw.Write(msg2) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg2))) + + n, err = sw.Write(msg3) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg3))) + }) + It("should write and flush messages to a JSON stream and set header once", func() { + respFlusher := newMockResponseFlusher(mockCtrl) + // expected calls to response writer + respFlusher.EXPECT().Header().Return(respHeader) + respFlusher.EXPECT().WriteHeader(http.StatusOK) + respFlusher.EXPECT().Write(resp1).Return(len(msg1), nil) + respFlusher.EXPECT().Write(resp2).Return(len(msg2), nil) + respFlusher.EXPECT().Write(resp3).Return(len(msg3), nil) + + // streamwriter should successfully write the messages, set header, and flush 3 times + sw := NewStreamWriter(respFlusher) + Expect(respFlusher.flushed).Should(Equal(0)) + n, err := sw.Write(msg1) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg1))) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + Expect(respFlusher.flushed).Should(Equal(1)) + + n, err = sw.Write(msg2) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg2))) + Expect(respFlusher.flushed).Should(Equal(2)) + + n, err = sw.Write(msg3) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg3))) + Expect(respFlusher.flushed).Should(Equal(3)) + }) + It("should stream messages and an error, but set header only once", func() { + // expected calls to response writer + respWriter.EXPECT().Header().Return(respHeader) + respWriter.EXPECT().WriteHeader(http.StatusOK) + respWriter.EXPECT().Write(resp1).Return(len(msg1), nil) + respWriter.EXPECT().Write(resp2).Return(len(msg2), nil) + respWriter.EXPECT().Write(errResp).Return(len(errResp), nil) + + // streamwriter should successfully write messages, set header, and write an error response + sw := NewStreamWriter(respWriter) + n, err := sw.Write(msg1) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg1))) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + + n, err = sw.Write(msg2) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg2))) + + err = sw.WriteError(http.StatusInternalServerError, errMsg) + Expect(err).Should(BeNil()) + }) + It("should return an error message with status code without streaming", func() { + var err error + errResp, err = json.Marshal(NewError(errMsg)) + Expect(err).Should(BeNil()) + errResp = append(errResp, byte('\n')) + + // expected calls to response writer + respWriter.EXPECT().Header().Return(respHeader) + respWriter.EXPECT().WriteHeader(http.StatusInternalServerError) + respWriter.EXPECT().Write(errResp).Return(len(errResp), nil) + + // streamwriter should successfully write an error response with a status code + sw := NewStreamWriter(respWriter) + err = sw.WriteError(http.StatusInternalServerError, errMsg) + Expect(err).Should(BeNil()) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + }) + It("should stream messages and aux data, but set header only once", func() { + respFlusher := newMockResponseFlusher(mockCtrl) + // expected calls to response writer + respFlusher.EXPECT().Header().Return(respHeader) + respFlusher.EXPECT().WriteHeader(http.StatusOK) + respFlusher.EXPECT().Write(resp1).Return(len(msg1), nil) + respFlusher.EXPECT().Write(resp2).Return(len(msg2), nil) + respFlusher.EXPECT().Write(auxResp).Return(len(auxMsg), nil) + + // streamwriter should successfully write messages, set header, and write an aux response + sw := NewStreamWriter(respFlusher) + n, err := sw.Write(msg1) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg1))) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + Expect(respFlusher.flushed).Should(Equal(1)) + + n, err = sw.Write(msg2) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg2))) + Expect(respFlusher.flushed).Should(Equal(2)) + + err = sw.WriteAux(auxMsg) + Expect(err).Should(BeNil()) + Expect(respFlusher.flushed).Should(Equal(3)) + }) + It("should return an error when writing aux data failed", func() { + auxErr := fmt.Errorf("failed to write aux data") + // expected calls to response writer + respWriter.EXPECT().Header().Return(respHeader) + respWriter.EXPECT().WriteHeader(http.StatusOK) + respWriter.EXPECT().Write(auxResp).Return(0, auxErr) + + // streamwriter should successfully write messages, set header, and write an aux response + sw := NewStreamWriter(respWriter) + err := sw.WriteAux(auxMsg) + Expect(err).Should(Equal(auxErr)) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + }) + }) + + Context("pullJobWriter", func() { + It("should write messages as JSON stream objects after being resolved", func() { + resolvedMsg := []byte("resolved image") + resolvedResp := []byte(fmt.Sprintf(`{"stream":"%s"}`+"\n", resolvedMsg)) + + // expected calls to response writer + respWriter.EXPECT().Header().Return(respHeader) + respWriter.EXPECT().WriteHeader(http.StatusOK) + respWriter.EXPECT().Write(resolvedResp).Return(len(resolvedMsg), nil) + respWriter.EXPECT().Write(resp2).Return(len(msg2), nil) + + // pulljobwriter should only write messages after being resolved and set header once + sw := NewPullJobWriter(respWriter) + n, err := sw.Write(msg1) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(0)) + Expect(respHeader).Should(BeEmpty()) + Expect(sw.IsResolved()).Should(BeFalse()) + + n, err = sw.Write(resolvedMsg) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(resolvedMsg))) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + Expect(sw.IsResolved()).Should(BeTrue()) + + n, err = sw.Write(msg2) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(len(msg2))) + }) + It("should ignore all messages if not resolved and send an error message with status code", func() { + var err error + errResp, err = json.Marshal(NewError(errMsg)) + Expect(err).Should(BeNil()) + errResp = append(errResp, byte('\n')) + + // pulljobwriter will ignore all the messages + sw := NewPullJobWriter(respWriter) + n, err := sw.Write(msg1) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(0)) + Expect(respHeader).Should(BeEmpty()) + Expect(sw.IsResolved()).Should(BeFalse()) + + n, err = sw.Write(msg2) + Expect(err).Should(BeNil()) + Expect(n).Should(Equal(0)) + Expect(respHeader).Should(BeEmpty()) + Expect(sw.IsResolved()).Should(BeFalse()) + + // pulljobwriter will send the error message with status code + respWriter.EXPECT().Header().Return(respHeader) + respWriter.EXPECT().WriteHeader(http.StatusInternalServerError) + respWriter.EXPECT().Write(errResp).Return(len(errResp), nil) + err = sw.WriteError(http.StatusInternalServerError, errMsg) + Expect(err).Should(BeNil()) + Expect(respHeader).Should(HaveKeyWithValue("Content-Type", []string{"application/json"})) + Expect(sw.IsResolved()).Should(BeFalse()) + }) + }) +}) + +type mockResponseFlusher struct { + *mocks_http.MockResponseWriter + flushed int +} + +func newMockResponseFlusher(ctrl *gomock.Controller) *mockResponseFlusher { + return &mockResponseFlusher{ + MockResponseWriter: mocks_http.NewMockResponseWriter(ctrl), + flushed: 0, + } +} + +func (m *mockResponseFlusher) Flush() { + m.flushed += 1 +} diff --git a/pkg/api/router/router.go b/pkg/api/router/router.go new file mode 100644 index 00000000..3fe79bc5 --- /dev/null +++ b/pkg/api/router/router.go @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package router + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/containerd/nerdctl/pkg/config" + ghandlers "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/moby/moby/api/server/httputils" + "github.com/moby/moby/api/types/versions" + "github.com/runfinch/finch-daemon/pkg/api/handlers/builder" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/api/handlers/exec" + "github.com/runfinch/finch-daemon/pkg/api/handlers/image" + "github.com/runfinch/finch-daemon/pkg/api/handlers/network" + "github.com/runfinch/finch-daemon/pkg/api/handlers/system" + "github.com/runfinch/finch-daemon/pkg/api/handlers/volume" + "github.com/runfinch/finch-daemon/pkg/api/response" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/flog" + "github.com/runfinch/finch-daemon/pkg/version" +) + +// Options defines the router options to be passed into the handlers +type Options struct { + Config *config.Config + ContainerService container.Service + ImageService image.Service + NetworkService network.Service + SystemService system.Service + BuilderService builder.Service + VolumeService volume.Service + ExecService exec.Service + + // NerdctlWrapper wraps the interactions with nerdctl to build + NerdctlWrapper *backend.NerdctlWrapper +} + +// New creates a new router and registers the handlers to it. Returns a handler object +// The struct definitions of the HTTP responses come from https://github.com/moby/moby/tree/master/api/types. +func New(opts *Options) http.Handler { + r := mux.NewRouter() + r.Use(VersionMiddleware) + vr := types.VersionedRouter{Router: r} + + logger := flog.NewLogrus() + system.RegisterHandlers(vr, opts.SystemService, opts.Config, opts.NerdctlWrapper, logger) + image.RegisterHandlers(vr, opts.ImageService, opts.Config, logger) + container.RegisterHandlers(vr, opts.ContainerService, opts.Config, logger) + network.RegisterHandlers(vr, opts.NetworkService, opts.Config, logger) + builder.RegisterHandlers(vr, opts.BuilderService, opts.Config, logger, opts.NerdctlWrapper) + volume.RegisterHandlers(vr, opts.VolumeService, opts.Config, logger) + exec.RegisterHandlers(vr, opts.ExecService, opts.Config, logger) + return ghandlers.LoggingHandler(os.Stderr, r) +} + +// VersionMiddleware checks for the requested version of the api and makes sure it falls within the bounds +// of the supported version +func VersionMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + ctx := context.WithValue(r.Context(), httputils.APIVersionKey{}, version.DefaultApiVersion) + APIVersion, ok := vars["version"] + if ok { + if versions.LessThan(APIVersion, version.MinimumApiVersion) { + response.SendErrorResponse(w, http.StatusBadRequest, + fmt.Errorf("your api version, v%s, is below the minimum supported version, v%s", + APIVersion, version.MinimumApiVersion)) + return + } else if versions.GreaterThan(APIVersion, version.DefaultApiVersion) { + response.SendErrorResponse(w, http.StatusBadRequest, + fmt.Errorf("your api version, v%s, is newer than the server's version, v%s", + APIVersion, version.DefaultApiVersion)) + return + } + ctx = context.WithValue(r.Context(), httputils.APIVersionKey{}, APIVersion) + } + newReq := r.WithContext(ctx) + next.ServeHTTP(w, newReq) + }) +} diff --git a/pkg/api/router/router_test.go b/pkg/api/router/router_test.go new file mode 100644 index 00000000..b88a8434 --- /dev/null +++ b/pkg/api/router/router_test.go @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package router + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_system" + "github.com/runfinch/finch-daemon/pkg/version" +) + +// TestRouterFunctions is the entry point for unit tests in the router package +func TestRouterFunctions(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Router functions") +} + +// Unit tests for the version middleware +var _ = Describe("version middleware test", func() { + var ( + opts *Options + h http.Handler + rr *httptest.ResponseRecorder + expected types.VersionInfo + sysSvc *mocks_system.MockService + ) + + //TODO: rethink the unit test cases for the router. + BeforeEach(func() { + mockCtrl := gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + c := config.Config{} + sysSvc = mocks_system.NewMockService(mockCtrl) + opts = &Options{ + Config: &c, + ContainerService: nil, + ImageService: nil, + NetworkService: nil, + SystemService: sysSvc, + BuilderService: nil, + VolumeService: nil, + NerdctlWrapper: nil, + } + h = New(opts) + rr = httptest.NewRecorder() + expected = types.VersionInfo{ + Platform: struct { + Name string + }{}, + Version: "0.0.1", + ApiVersion: "1.43", + MinAPIVersion: "1.35", + GitCommit: "abcd", + Os: "linux", + Arch: "x86", + KernelVersion: "kernel-123", + Experimental: true, + Components: []types.ComponentVersion{ + { + Name: "containerd", + Version: "v1.7.1", + Details: map[string]string{ + "GitCommit": "1677a17964311325ed1c31e2c0a3589ce6d5c30d", + }}, + }, + } + sysSvc.EXPECT().GetVersion(gomock.Any()).Return(&expected, nil).AnyTimes() + }) + It("should return a 400 error for versions below the min supported", func() { + testVer := "1.11" + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/v%s/version", testVer), nil) + + h.ServeHTTP(rr, req) + + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body.String()).Should(MatchJSON(fmt.Sprintf( + `{"message": "your api version, v%s, is below the minimum supported version, v%s"}`, testVer, + version.MinimumApiVersion))) + }) + It("should return a 400 error for versions above the default supported", func() { + testVer := "1.99" + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/v%s/version", testVer), nil) + + h.ServeHTTP(rr, req) + + Expect(rr).Should(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr.Body.String()).Should(MatchJSON(fmt.Sprintf( + `{"message": "your api version, v%s, is newer than the server's version, v%s"}`, testVer, + version.DefaultApiVersion))) + }) + It("should parse a versioned route correctly and return 200 success", func() { + testVer := "1.40" + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/v%s/version", testVer), nil) + + h.ServeHTTP(rr, req) + + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + jd := json.NewDecoder(rr.Body) + var v types.VersionInfo + err := jd.Decode(&v) + Expect(err).ShouldNot(HaveOccurred()) + Expect(v).Should(Equal(expected)) + }) + It("should parse a non-versioned route correctly and return 200 success", func() { + req, _ := http.NewRequest(http.MethodGet, "http://localhost/version", nil) + + h.ServeHTTP(rr, req) + + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + jd := json.NewDecoder(rr.Body) + var v types.VersionInfo + err := jd.Decode(&v) + Expect(err).ShouldNot(HaveOccurred()) + Expect(v).Should(Equal(expected)) + }) +}) diff --git a/pkg/api/types/build_types.go b/pkg/api/types/build_types.go new file mode 100644 index 00000000..a53bb38f --- /dev/null +++ b/pkg/api/types/build_types.go @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// BuildResult contains the image id of a successful build +// From https://github.com/moby/moby/blob/v24.0.2/api/types/types.go#L774-L777 +type BuildResult struct { + ID string +} diff --git a/pkg/api/types/container_types.go b/pkg/api/types/container_types.go new file mode 100644 index 00000000..9e143eb8 --- /dev/null +++ b/pkg/api/types/container_types.go @@ -0,0 +1,236 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "io" + "os" + "time" + + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/go-connections/nat" +) + +// AttachOptions defines the available options for the container attach call +type AttachOptions struct { + GetStreams func() (io.Writer, io.Writer, chan os.Signal, func(), error) + UseStdin bool + UseStdout bool + UseStderr bool + Logs bool + Stream bool + // TODO: DetachKeys string + MuxStreams bool +} + +// ContainerConfig is from https://github.com/moby/moby/blob/v24.0.2/api/types/container/config.go#L64-L96 +type ContainerConfig struct { + Hostname string `json:",omitempty"` // Hostname + // TODO: Domainname string // Domainname + User string `json:",omitempty"` // User that will run the command(s) inside the container, also support user:group + AttachStdin bool // Attach the standard input, makes possible user interaction + // TODO: AttachStdout bool // Attach the standard output + // TODO: AttachStderr bool // Attach the standard error + ExposedPorts nat.PortSet `json:",omitempty"` // List of exposed ports + Tty bool // Attach standard streams to a tty, including stdin if it is not closed. + // TODO: OpenStdin bool // Open stdin + // TODO: StdinOnce bool // If true, close stdin after the 1 attached client disconnects. + Env []string `json:",omitempty"` // List of environment variable to set in the container + Cmd []string `json:",omitempty"` // Command to run when starting the container + // TODO Healthcheck *HealthConfig `json:",omitempty"` // Healthcheck describes how to check the container is healthy + // TODO: ArgsEscaped bool `json:",omitempty"` // True if command is already escaped (meaning treat as a command line) (Windows specific). + Image string // Name of the image as it was passed by the operator (e.g. could be symbolic) + Volumes map[string]struct{} `json:",omitempty"` // List of volumes (mounts) used for the container + WorkingDir string `json:",omitempty"` // Current directory (PWD) in the command will be launched + Entrypoint []string `json:",omitempty"` // Entrypoint to run when starting the container + // TODO: NetworkDisabled bool `json:",omitempty"` // Is network disabled + // TODO: MacAddress string `json:",omitempty"` // Mac Address of the container + // TODO: OnBuild []string // ONBUILD metadata that were defined on the image Dockerfile + Labels map[string]string `json:",omitempty"` // List of labels set to this container + StopSignal string `json:",omitempty"` // Signal to stop a container + StopTimeout *int `json:",omitempty"` // Timeout (in seconds) to stop a container + // TODO: Shell []string `json:",omitempty"` // Shell for shell-form of RUN, CMD, ENTRYPOINT +} + +// HostConfig is from https://github.com/moby/moby/blob/v24.0.2/api/types/container/hostconfig.go#L376-L436 +type ContainerHostConfig struct { + // Applicable to all platforms + Binds []string // List of volume bindings for this container + // TODO: ContainerIDFile string // File (path) where the containerId is written + // TODO: LogConfig LogConfig // Configuration of the logs for this container + NetworkMode string // Network mode to use for the container + PortBindings nat.PortMap // Port mapping between the exposed port (container) and the host + // TODO: RestartPolicy RestartPolicy // Restart policy to be used for the container + AutoRemove bool // Automatically remove container when it exits + // TODO: VolumeDriver string // Name of the volume driver used to mount volumes + // TODO: VolumesFrom []string // List of volumes to take from other container + // TODO: ConsoleSize [2]uint // Initial console size (height,width) + // TODO: Annotations map[string]string `json:",omitempty"` // Arbitrary non-identifying metadata attached to container and provided to the runtime + + // Applicable to UNIX platforms + CapAdd []string // List of kernel capabilities to add to the container + // TODO: CapDrop strslice.StrSlice // List of kernel capabilities to remove from the container + // TODO: CgroupnsMode CgroupnsMode // Cgroup namespace mode to use for the container + // TODO: DNS []string `json:"Dns"` // List of DNS server to lookup + // TODO: DNSOptions []string `json:"DnsOptions"` // List of DNSOption to look for + // TODO: DNSSearch []string `json:"DnsSearch"` // List of DNSSearch to look for + // TODO: ExtraHosts []string // List of extra hosts + // TODO: GroupAdd []string // List of additional groups that the container process will run as + // TODO: IpcMode IpcMode // IPC namespace to use for the container + // TODO: Cgroup CgroupSpec // Cgroup to use for the container + // TODO: Links []string // List of links (in the name:alias form) + // TODO: OomScoreAdj int // Container preference for OOM-killing + // TODO: PidMode PidMode // PID namespace to use for the container + // TODO: Privileged bool // Is the container in privileged mode + // TODO: PublishAllPorts bool // Should docker publish all exposed port for the container + // TODO: ReadonlyRootfs bool // Is the container root filesystem in read-only + // TODO: SecurityOpt []string // List of string values to customize labels for MLS systems, such as SELinux. + // TODO: StorageOpt map[string]string `json:",omitempty"` // Storage driver options per container. + // TODO: Tmpfs map[string]string `json:",omitempty"` // List of tmpfs (mounts) used for the container + // TODO: UTSMode UTSMode // UTS namespace to use for the container + // TODO: UsernsMode UsernsMode // The user namespace to use for the container + // TODO: ShmSize int64 // Total shm memory usage + // TODO: Sysctls map[string]string `json:",omitempty"` // List of Namespaced sysctls used for the container + // TODO: Runtime string `json:",omitempty"` // Runtime to use with this container + + // Applicable to Windows + // TODO: Isolation Isolation // Isolation technology of the container (e.g. default, hyperv) + + // Contains container's resources (cgroups, ulimits) + Memory int64 // Memory limit (in bytes) + // TODO: Resources + + // Mounts specs used by the container + // TODO: Mounts []mount.Mount `json:",omitempty"` + + // MaskedPaths is the list of paths to be masked inside the container (this overrides the default set of paths) + // TODO: MaskedPaths []string + + // ReadonlyPaths is the list of paths to be set as read-only inside the container (this overrides the default set of paths) + // TODO: ReadonlyPaths []string + + // Run a custom init inside the container, if null, use the daemon's configured settings + // TODO: Init *bool `json:",omitempty"` +} + +type ContainerCreateRequest struct { + ContainerConfig + HostConfig ContainerHostConfig + // TODO: NetworkingConfig ContainerNetworkingConfig +} + +// Container mimics a `docker container inspect` object. +// From https://github.com/moby/moby/blob/v24.0.2/api/types/types.go#L445-L486 +type Container struct { + ID string `json:"Id"` + Created string + Path string + Args []string + State *dockercompat.ContainerState + Image string + ResolvConfPath string + HostnamePath string + // TODO: HostsPath string + LogPath string + // Unimplemented: Node *ContainerNode `json:",omitempty"` // Node is only propagated by Docker Swarm standalone API + Name string + RestartCount int + Driver string + Platform string + // TODO: MountLabel string + // TODO: ProcessLabel string + AppArmorProfile string + // TODO: ExecIDs []string + // TODO: HostConfig *container.HostConfig + // TODO: GraphDriver GraphDriverData + // TODO: SizeRw *int64 `json:",omitempty"` + // TODO: SizeRootFs *int64 `json:",omitempty"` + + Mounts []dockercompat.MountPoint + Config *ContainerConfig + NetworkSettings *dockercompat.NetworkSettings +} + +type ContainerListItem struct { + Id string `json:"Id"` + Names []string `json:"Names"` + Image string + CreatedAt int64 `json:"Created"` + State string `json:"State"` + Labels map[string]string + NetworkSettings *dockercompat.NetworkSettings + Mounts []dockercompat.MountPoint + // TODO: Other fields +} + +// LogsOptions defines the available options for the container logs call +type LogsOptions struct { + GetStreams func() (io.Writer, io.Writer, chan os.Signal, func(), error) + Stdout bool + Stderr bool + Follow bool + Since int64 + Until int64 + Timestamps bool + Tail string + MuxStreams bool +} + +// PutArchiveOptions defines the parameters for [PutContainerArchive API](https://docs.docker.com/engine/api/v1.41/#tag/Container/operation/PutContainerArchive) +type PutArchiveOptions struct { + ContainerId string + Path string + Overwrite bool + CopyUIDGID bool +} + +// CPUStats aggregates and wraps all CPU related info of container +// From https://github.com/moby/moby/blob/v24.0.2/api/types/stats.go#L42-L55 +type CPUStats struct { + // CPU Usage. Linux and Windows. + CPUUsage dockertypes.CPUUsage `json:"cpu_usage"` + + // System Usage. Linux only. + SystemUsage uint64 `json:"system_cpu_usage,omitempty"` + + // Online CPUs. Linux only. + OnlineCPUs uint32 `json:"online_cpus,omitempty"` + + // Throttling Data. Linux only. + // TODO: ThrottlingData ThrottlingData `json:"throttling_data,omitempty"` +} + +// Stats is Ultimate struct aggregating all types of stats of one container +// From https://github.com/moby/moby/blob/v24.0.2/api/types/stats.go#L152-L170 +type Stats struct { + // Common stats + Read time.Time `json:"read"` + PreRead time.Time `json:"preread"` + + // Linux specific stats, not populated on Windows. + PidsStats dockertypes.PidsStats `json:"pids_stats,omitempty"` + BlkioStats dockertypes.BlkioStats `json:"blkio_stats,omitempty"` + + // Windows specific stats, not populated on Linux. + // NumProcs uint32 `json:"num_procs"` + // StorageStats StorageStats `json:"storage_stats,omitempty"` + + // Shared stats + CPUStats CPUStats `json:"cpu_stats,omitempty"` + PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous" + MemoryStats dockertypes.MemoryStats `json:"memory_stats,omitempty"` +} + +// StatsJSON is the JSON response for container stats api +// From https://github.com/moby/moby/blob/v24.0.2/api/types/stats.go#L172-L181 +type StatsJSON struct { + Stats + + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + + // Networks request version >=1.21 + Networks map[string]dockertypes.NetworkStats `json:"networks,omitempty"` +} diff --git a/pkg/api/types/exec_types.go b/pkg/api/types/exec_types.go new file mode 100644 index 00000000..c3f5fb09 --- /dev/null +++ b/pkg/api/types/exec_types.go @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "io" + +// ExecConfig is from https://github.com/moby/moby/blob/454b6a7cf5187d1153159e5fe07f4b25c7a95e7f/api/types/configs.go#L30-L45 +type ExecConfig struct { + User string // User that will run the command + Privileged bool // Is the container in privileged mode + Tty bool // Attach standard streams to a tty. + ConsoleSize *[2]uint `json:",omitempty"` // Initial console size [height, width] + AttachStdin bool // Attach the standard input, makes possible user interaction + AttachStderr bool // Attach the standard error + AttachStdout bool // Attach the standard output + Detach bool // Execute in detach mode + DetachKeys string // Escape keys for detach + Env []string // Environment variables + WorkingDir string // Working directory + Cmd []string // Execution commands and args +} + +type ExecCreateResponse struct { + Id string +} + +// ExecStartCheck is from https://github.com/moby/moby/blob/454b6a7cf5187d1153159e5fe07f4b25c7a95e7f/api/types/types.go#L231-L240 +type ExecStartCheck struct { + // ExecStart will first check if it's detached + Detach bool + // Check if there's a tty + Tty bool + // Terminal size [height, width], unused if Tty == false + ConsoleSize *[2]uint `json:",omitempty"` +} + +type ExecStartOptions struct { + *ExecStartCheck + ConID string + ExecID string + Stdin io.ReadCloser + Stdout io.Writer + Stderr io.Writer + SuccessResponse func() +} + +type ExecResizeOptions struct { + ConID string + ExecID string + Height int + Width int +} + +// ExecInspect holds information about a running process started +// with docker exec. +// from https://github.com/moby/moby/blob/454b6a7cf5187d1153159e5fe07f4b25c7a95e7f/api/types/backend/backend.go#L77-L91 +type ExecInspect struct { + ID string + Running bool + ExitCode *int + ProcessConfig *ExecProcessConfig + OpenStdin bool + OpenStderr bool + OpenStdout bool + CanRemove bool + ContainerID string + DetachKeys []byte + Pid int +} + +// ExecProcessConfig holds information about the exec process +// running on the host. +// from https://github.com/moby/moby/blob/454b6a7cf5187d1153159e5fe07f4b25c7a95e7f/api/types/backend/backend.go#L93-L101 +type ExecProcessConfig struct { + Tty bool `json:"tty"` + Entrypoint string `json:"entrypoint"` + Arguments []string `json:"arguments"` + Privileged *bool `json:"privileged,omitempty"` + User string `json:"user,omitempty"` +} diff --git a/pkg/api/types/image_types.go b/pkg/api/types/image_types.go new file mode 100644 index 00000000..72847715 --- /dev/null +++ b/pkg/api/types/image_types.go @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +/* +ImageSummary models a single item in the list response to /images/json in the +Docker API. +https://docs.docker.com/engine/api/v1.40/#operation/ImageList +*/ +type ImageSummary struct { + ID string `json:"Id"` + RepoTags []string + RepoDigests []string + Created int64 + Size int64 +} + +// PushResult contains the tag, manifest digest, and manifest size from the +// push. It's used to signal this information to the trust code in the client +// so it can sign the manifest if necessary. +// From https://github.com/moby/moby/blob/v24.0.2/api/types/types.go#L765-L772 +type PushResult struct { + Tag string + Digest string + Size int +} diff --git a/pkg/api/types/network_types.go b/pkg/api/types/network_types.go new file mode 100644 index 00000000..877f4468 --- /dev/null +++ b/pkg/api/types/network_types.go @@ -0,0 +1,219 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + +// NetworkCreateOption is a utility type for setting +// create network configuration on requests via options. +type NetworkCreateOption func(*NetworkCreateRequest) + +// NewCreateNetworkRequest is a utility function for instantiating +// create network requests with both required and optional configuration. +func NewCreateNetworkRequest(name string, opts ...NetworkCreateOption) *NetworkCreateRequest { + request := &NetworkCreateRequest{Name: name} + + for _, opt := range opts { + opt(request) + } + + return request +} + +// WithDriver configures the name of the network driver to use. +func WithDriver(driver string) NetworkCreateOption { + return func(r *NetworkCreateRequest) { + r.Driver = driver + } +} + +// WithInternal enables/disables internal-only access to the network. +func WithInternal(isInternal bool) NetworkCreateOption { + return func(r *NetworkCreateRequest) { + r.Internal = isInternal + } +} + +// WithAttachable enables/disables manual container attachment. +func WithAttachable(isAttachable bool) NetworkCreateOption { + return func(r *NetworkCreateRequest) { + r.Attachable = isAttachable + } +} + +// WithIngress enables/disables creating ingress network with routing-mesh in swarm mode. +func WithIngress(isIngress bool) NetworkCreateOption { + return func(r *NetworkCreateRequest) { + r.Ingress = isIngress + } +} + +// WithIPAM configures the IP Address Management (IPAM) driver configuration +// and options. +func WithIPAM(ipam IPAM) NetworkCreateOption { + return func(r *NetworkCreateRequest) { + r.IPAM = ipam + } +} + +// WithEnableIPv6 enables/disables Internet Protocol version 6 (IPv6) networking. +func WithEnableIPv6(isIPv6 bool) NetworkCreateOption { + return func(r *NetworkCreateRequest) { + r.EnableIPv6 = isIPv6 + } +} + +// WithOptions configures network driver specific options. +func WithOptions(options map[string]string) NetworkCreateOption { + return func(r *NetworkCreateRequest) { + r.Options = options + } +} + +// WithLabels configures user-defined key-value metadata on a network. +func WithLabels(labels map[string]string) NetworkCreateOption { + return func(r *NetworkCreateRequest) { + r.Labels = labels + } +} + +// NetworkCreateRequest is a data class for simple JSON marshalling/unmarshalling +// of /networks/create messages into HTTP Post requests. +// +// Reference: https://docs.docker.com/engine/api/v1.43/#tag/Network/operation/NetworkCreate +// +// Example: +// +// { +// "Name": "isolated_nw", +// "CheckDuplicate": false, +// "Driver": "bridge", +// "EnableIPv6": true, +// "IPAM": { +// "Driver": "default", +// "Config": [ +// { +// "Subnet": "172.20.0.0/16", +// "IPRange": "172.20.10.0/24", +// "Gateway": "172.20.10.11" +// }, +// { +// "Subnet": "2001:db8:abcd::/64", +// "Gateway": "2001:db8:abcd::1011" +// } +// ], +// "Options": { +// "foo": "bar" +// } +// }, +// "Internal": true, +// "Attachable": false, +// "Ingress": false, +// "Options": { +// "com.docker.network.bridge.default_bridge": "true", +// "com.docker.network.bridge.enable_icc": "true", +// "com.docker.network.bridge.enable_ip_masquerade": "true", +// "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0", +// "com.docker.network.bridge.name": "docker0", +// "com.docker.network.driver.mtu": "1500" +// }, +// "Labels": { +// "com.example.some-label": "some-value", +// "com.example.some-other-label": "some-other-value" +// } +// } +type NetworkCreateRequest struct { + // Name is the network's name. + Name string `json:"Name"` + + // CheckDuplicate specifies to check for networks with duplicate names. + // + // Deprecated: The backend (nerdctl) will always check for collisions. + CheckDuplicate bool `json:"CheckDuplicate"` + + // Driver is the name of the network driver plugin to use. + Driver string `json:"Driver" default:"bridge"` + + // Internal specifies to restrict external access to the network. + // + // Internal networks are not currently supported. + Internal bool `json:"Internal"` + + // Attachable specifies if a globally scoped network is manually attachable + // by regular containers from workers in swarm mode. + // + // Attachable networks are not currently supported. + Attachable bool `json:"Attachable"` + + // Ingress specifies if the network should be an ingress network and provide + // the routing-mesh in swarm mode. + // + // Ingress networks are not currently supported. + Ingress bool `json:"Ingress"` + + // IPAM specifies customer IP Address Management (IPAM) configuration. + IPAM IPAM `json:"IPAM"` + + // EnableIPv6 specifies to enable IPv6 on the network. + // + // IPv6 networks are not currently supported. + EnableIPv6 bool `json:"EnableIPv6"` + + // Options specifies network specific options to be used by the drivers. + Options map[string]string `json:"Options"` + + // Labels are user-defined key-value network metadata + Labels map[string]string `json:"Labels"` +} + +// IPAM is a data class for simple JSON marshalling/unmarshalling +// of IP Address Management (IPAM) network configuration. +// +// Reference: https://github.com/moby/libnetwork/blob/2267b2527259eff27aa330b35de964afbbb4392e/docs/ipam.md +type IPAM struct { + // Driver is the name of the IPAM driver to use. + Driver string `json:"Driver" default:"default"` + + // Config is a list of IPAM configuration options. + Config []map[string]string `json:"Config"` + + // Options are driver-specific options as a key-value mapping. + Options map[string]string `json:"Options"` +} + +// NetworkCreateResponse is a data class for simple JSON marshalling/unmarshalling +// of /networks/create messages into HTTP Post responses. +// +// Reference: https://docs.docker.com/engine/api/v1.43/#tag/Network/operation/NetworkCreate +// +// Example: +// +// { +// "Id": "22be93d5babb089c5aab8dbc369042fad48ff791584ca2da2100db837a1c7c30", +// "Warning": "" +// } +type NetworkCreateResponse struct { + // ID is the unique identification document for the network that was created. + ID string `json:"Id"` + + // Warning is used to communicate any issues which occurred during network configuration. + Warning string `json:"Warning,omitempty"` +} + +// NetworkInspectResponse models a single network object in response to /networks/{id} +type NetworkInspectResponse struct { + Name string `json:"Name"` + ID string `json:"Id"` + // Created string `json:"Created"` + // Scope string `json:"Scope"` + // Driver string `json:"Driver"` + // EnableIPv6 bool `json:"EnableIPv6"` + // Internal bool `json:"Internal"` + // Attachable bool `json:"Attachable"` + // Ingress bool `json:"Ingress"` + IPAM dockercompat.IPAM `json:"IPAM,omitempty"` + // Containers ContainersType `json:"Containers"` + // Options OptionsType `json:"Options"` + Labels map[string]string `json:"Labels,omitempty"` +} diff --git a/pkg/api/types/router_types.go b/pkg/api/types/router_types.go new file mode 100644 index 00000000..23fb8b53 --- /dev/null +++ b/pkg/api/types/router_types.go @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +// VersionedRouter wraps the router to provide a router that can redirect routes to either a versioned +// path or a non-versioned path +type VersionedRouter struct { + Router *mux.Router + prefix string +} + +// HandleFunc replaces the router.HandleFunc function to create a new handler and direct certain paths +// to certain functions +func (vr *VersionedRouter) HandleFunc(path string, f func(http.ResponseWriter, *http.Request), methods ...string) { + vr.Router.HandleFunc(vr.prefix+path, f).Methods(methods...) + vr.Router.HandleFunc("/v{version}"+vr.prefix+path, f).Methods(methods...) +} + +// SetPrefix sets the prefix of the route, so that any specified routes can just specify the endpoint only +func (vr *VersionedRouter) SetPrefix(prefix string) { + vr.prefix = prefix +} diff --git a/pkg/api/types/system_types.go b/pkg/api/types/system_types.go new file mode 100644 index 00000000..7a481014 --- /dev/null +++ b/pkg/api/types/system_types.go @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// VersionInfo contains the response of /version api. +type VersionInfo struct { + Platform struct { + Name string + } + Version string + ApiVersion string + MinAPIVersion string + GitCommit string + Os string + Arch string + KernelVersion string + Experimental bool + BuildTime string + Components []ComponentVersion +} + +// ComponentVersion describes the version information for a specific component. +// From https://github.com/moby/moby/blob/v20.10.8/api/types/types.go#L112-L117 +type ComponentVersion struct { + Name string + Version string + Details map[string]string `json:",omitempty"` +} diff --git a/pkg/api/types/volumes_types.go b/pkg/api/types/volumes_types.go new file mode 100644 index 00000000..27eb6232 --- /dev/null +++ b/pkg/api/types/volumes_types.go @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "github.com/containerd/nerdctl/pkg/inspecttypes/native" + +// VolumesListResponse is the response object expected by GET /volumes +// https://docs.docker.com/engine/api/v1.40/#tag/Volume +type VolumesListResponse struct { + Volumes []native.Volume `json:"Volumes"` +} diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go new file mode 100644 index 00000000..12a61783 --- /dev/null +++ b/pkg/archive/archive.go @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package archive + +import ( + "io" + "os" + "path" + + "github.com/containerd/nerdctl/pkg/tarutil" + "github.com/docker/docker/pkg/archive" + "github.com/runfinch/finch-daemon/pkg/ecc" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +//go:generate mockgen --destination=../mocks/mocks_archive/tarcreator.go -package=mocks_archive github.com/runfinch/finch-daemon/pkg/archive TarCreator +type TarCreator interface { + CreateTarCommand(srcPath string, slashDot bool) (ecc.ExecCmd, error) +} + +type tarCreator struct { + ecc ecc.ExecCmdCreator + logger flog.Logger +} + +func NewTarCreator(ecc ecc.ExecCmdCreator, logger flog.Logger) TarCreator { + return &tarCreator{ + ecc: ecc, + logger: logger, + } +} + +// CreateTarCommand creates an *exec.Cmd that will output a tar archive of the provided srcPath to stdout +func (c *tarCreator) CreateTarCommand(srcPath string, slashDot bool) (ecc.ExecCmd, error) { + tarBinary, _, err := tarutil.FindTarBinary() + if err != nil { + c.logger.Debugf("error getting tar binary: %s", err.Error()) + return nil, err + } + + // "/." is a Docker thing that instructions the copy command to download contents of the folder only + var tarDir, tarPath string + if slashDot { + tarDir = srcPath + tarPath = "." + } else { + tarDir = path.Dir(srcPath) + tarPath = path.Base(srcPath) + } + + cmd := c.ecc.Command(tarBinary, []string{"-c", "-f", "-", tarPath}...) + cmd.SetDir(tarDir) + return cmd, nil +} + +// TarExtractor interface to extract a tar file +// +//go:generate mockgen --destination=../mocks/mocks_archive/tarextractor.go -package=mocks_archive github.com/runfinch/finch-daemon/pkg/archive TarExtractor +type TarExtractor interface { + ExtractInTemp(reader io.Reader, dirPrefix string) (ecc.ExecCmd, error) + CreateExtractCmd(reader io.Reader, destDir string) (ecc.ExecCmd, error) + Cleanup(cmd ecc.ExecCmd) + ExtractCompressed(tarArchive io.Reader, dest string, options *archive.TarOptions) error +} + +// tarExtractor struct in an implementation of TarExtractor. It extracts uncompressed tar file +type tarExtractor struct { + ecc ecc.ExecCmdCreator + logger flog.Logger +} + +// NewTarExtractor creates a new UncompressedTarExtractor +func NewTarExtractor(ecc ecc.ExecCmdCreator, logger flog.Logger) TarExtractor { + return &tarExtractor{ + ecc: ecc, + logger: logger, + } +} + +// ExtractInTemp is implementation of TarExtractor interface. This function extracts a tar file in a dest dir +func (ext *tarExtractor) ExtractInTemp(reader io.Reader, dirPrefix string) (ecc.ExecCmd, error) { + dir, err := os.MkdirTemp(os.TempDir(), dirPrefix) + if err != nil { + ext.logger.Errorf("Failed to extract in %s, error: %s", dir, err.Error()) + return nil, err + } + return ext.CreateExtractCmd(reader, dir) +} + +// CreateExtractCmd is implementation of TarExtractor interface. This function extracts a tar file in a dest dir +func (ext *tarExtractor) CreateExtractCmd(reader io.Reader, destDir string) (ecc.ExecCmd, error) { + tarBinary, _, err := tarutil.FindTarBinary() + if err != nil { + ext.logger.Debugf("error getting tar binary: %s", err.Error()) + return nil, err + } + cmd := ext.ecc.Command(tarBinary, []string{"-x", "-f", "-"}...) + cmd.SetStdin(reader) + cmd.SetDir(destDir) + return cmd, nil +} + +// Cleanup function delete the extracted directory +func (ext *tarExtractor) Cleanup(cmd ecc.ExecCmd) { + // clean up not required + if cmd == nil { + ext.logger.Debugf("noting to clean up.") + return + } + if err := os.RemoveAll(cmd.GetDir()); err != nil { + ext.logger.Debugf("unable to cleanup folder. path: %s", cmd.GetDir()) + } else { + ext.logger.Debugf("successfully cleaned up folder. path: %s", cmd.GetDir()) + } +} + +// Wraps https://github.com/moby/moby/blob/master/pkg/archive/archive.go#L1233 +// ExtractCompressed reads a stream of bytes from `archive`, parses it as a tar archive, +// and unpacks it into the directory at `dest`. +// The archive may be compressed with one of the following algorithms: +// identity (uncompressed), gzip, bzip2, xz. + +func (ext *tarExtractor) ExtractCompressed(tarArchive io.Reader, dest string, options *archive.TarOptions) error { + return archive.Untar(tarArchive, dest, options) +} diff --git a/pkg/archive/archive_test.go b/pkg/archive/archive_test.go new file mode 100644 index 00000000..ffed2046 --- /dev/null +++ b/pkg/archive/archive_test.go @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package archive + +import ( + "archive/tar" + "bytes" + "fmt" + "os" + "testing" + + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pkg/errors" + "github.com/runfinch/finch-daemon/pkg/ecc" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_ecc" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// TestContainerHandler function is the entry point of container handler package's unit test using ginkgo +func TestContainerHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Archive Utils") +} + +// Unit tests related to check RegisterHandlers() has configured the endpoint properly for containers related API +var _ = Describe("TarExtractor's ", func() { + + Context("ExtractInTempDir method", func() { + var ( + mockCtrl *gomock.Controller + cmdCreator ecc.ExecCmdCreator + logger *mocks_logger.Logger + tarExtractor TarExtractor + buf bytes.Buffer + dockerFileContent string + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + cmdCreator = ecc.NewExecCmdCreator() + logger = mocks_logger.NewLogger(mockCtrl) + tarExtractor = NewTarExtractor(cmdCreator, logger) + tw := tar.NewWriter(&buf) + dockerFileContent = "FROM alpine:latest" + hdr := &tar.Header{ + Name: "Dockerfile", + Mode: 0600, + Size: int64(len(dockerFileContent)), + } + _ = tw.WriteHeader(hdr) + _, _ = tw.Write([]byte(dockerFileContent)) + _ = tw.Close() + + }) + It("should be able to extract a tar file in temp folder", func() { + logger.EXPECT().Debugf("successfully cleaned up folder. path: %s", gomock.Any()) + + cmd, err := tarExtractor.ExtractInTemp(bytes.NewReader(buf.Bytes()), "unit-test") + defer tarExtractor.Cleanup(cmd) + err = cmd.Run() + Expect(err).Should(BeNil()) + dockerFile := fmt.Sprintf("%s/Dockerfile", cmd.GetDir()) + Expect(err).Should(BeNil()) + _, err = os.Stat(dockerFile) + Expect(err).Should(BeNil()) + b, _ := os.ReadFile(dockerFile) + Expect(string(b)).Should(Equal(dockerFileContent)) + }) + }) + Context("Cleanup method", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + mockCmd *mocks_ecc.MockExecCmd + tarExtractor TarExtractor + dir string + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + ecc := mocks_ecc.NewMockExecCmdCreator(mockCtrl) + mockCmd = mocks_ecc.NewMockExecCmd(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + tarExtractor = NewTarExtractor(ecc, logger) + dir, _ = os.MkdirTemp(os.TempDir(), "test") + mockCmd.EXPECT().GetDir().Return(dir).AnyTimes() + }) + AfterEach(func() { + mockCtrl.Finish() + //remove the folder if it is not deleted + _ = os.RemoveAll(dir) + }) + It("should be able be able to clean up files", func() { + logger.EXPECT().Debugf("successfully cleaned up folder. path: %s", gomock.Any()) + tarExtractor.Cleanup(mockCmd) + _, err := os.Stat(mockCmd.GetDir()) + Expect(errors.Is(err, os.ErrNotExist)).Should(BeTrue()) + }) + It("should skip cleaning as no path provided", func() { + logger.EXPECT().Debugf("noting to clean up.") + tarExtractor.Cleanup(nil) + }) + }) +}) diff --git a/pkg/backend/backend.go b/pkg/backend/backend.go new file mode 100644 index 00000000..3836fe3e --- /dev/null +++ b/pkg/backend/backend.go @@ -0,0 +1,616 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package backend uses interfaces and structs to create abstractions for nerdctl and containerd function calls, +// which allows mock creation for unit testing using mockgen. +package backend + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "time" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/cio" + "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/events" + "github.com/containerd/containerd/images" + "github.com/containerd/containerd/images/converter" + "github.com/containerd/containerd/mount" + "github.com/containerd/containerd/oci" + "github.com/containerd/containerd/pkg/cap" + "github.com/containerd/containerd/platforms" + refdocker "github.com/containerd/containerd/reference/docker" + "github.com/containerd/containerd/remotes" + "github.com/containerd/containerd/remotes/docker" + dockerconfig "github.com/containerd/containerd/remotes/docker/config" + "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/buildkitutil" + "github.com/containerd/nerdctl/pkg/clientutil" + "github.com/containerd/nerdctl/pkg/cmd/builder" + "github.com/containerd/nerdctl/pkg/cmd/container" + "github.com/containerd/nerdctl/pkg/cmd/volume" + "github.com/containerd/nerdctl/pkg/containerinspector" + "github.com/containerd/nerdctl/pkg/containerutil" + "github.com/containerd/nerdctl/pkg/idutil/imagewalker" + "github.com/containerd/nerdctl/pkg/imageinspector" + "github.com/containerd/nerdctl/pkg/imgutil" + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + "github.com/containerd/nerdctl/pkg/imgutil/push" + "github.com/containerd/nerdctl/pkg/infoutil" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/nerdctl/pkg/logging" + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containerd/nerdctl/pkg/referenceutil" + "github.com/containernetworking/cni/libcni" + "github.com/containernetworking/cni/pkg/invoke" + cnitypes "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/version" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +//go:generate mockgen --destination=../mocks/mocks_backend/nerdctlimagesvc.go -package=mocks_backend github.com/runfinch/finch-daemon/pkg/backend NerdctlImageSvc +type NerdctlImageSvc interface { + InspectImage(ctx context.Context, image images.Image) (*dockercompat.Image, error) + GetDockerResolver(ctx context.Context, refDomain string, creds dockerconfigresolver.AuthCreds) (remotes.Resolver, docker.StatusTracker, error) + PullImage(ctx context.Context, stdout, stderr io.Writer, resolver remotes.Resolver, ref string, platforms []ocispec.Platform) (*imgutil.EnsuredImage, error) + PushImage(ctx context.Context, resolver remotes.Resolver, tracker docker.StatusTracker, stdout io.Writer, pushRef, ref string, platMC platforms.MatchComparer) error + SearchImage(ctx context.Context, name string) (int, int, []*images.Image, error) +} + +//go:generate mockgen --destination=../mocks/mocks_backend/nerdctlbuildersvc.go -package=mocks_backend github.com/runfinch/finch-daemon/pkg/backend NerdctlBuilderSvc +type NerdctlBuilderSvc interface { + Build(ctx context.Context, client ContainerdClient, options types.BuilderBuildOptions) error + GetBuildkitHost() (string, error) +} + +//go:generate mockgen --destination=../mocks/mocks_backend/nerdctlcontainersvc.go -package=mocks_backend github.com/runfinch/finch-daemon/pkg/backend NerdctlContainerSvc +type NerdctlContainerSvc interface { + RemoveContainer(ctx context.Context, c containerd.Container, force bool, removeAnonVolumes bool) error + StartContainer(ctx context.Context, container containerd.Container) error + StopContainer(ctx context.Context, container containerd.Container, timeout *time.Duration) error + CreateContainer(ctx context.Context, args []string, netManager containerutil.NetworkOptionsManager, options types.ContainerCreateOptions) (containerd.Container, func(), error) + InspectContainer(ctx context.Context, c containerd.Container) (*dockercompat.Container, error) + InspectNetNS(ctx context.Context, pid int) (*native.NetNS, error) + NewNetworkingOptionsManager(types.NetworkOptions) (containerutil.NetworkOptionsManager, error) + ListContainers(ctx context.Context, options types.ContainerListOptions) ([]container.ListItem, error) + RenameContainer(ctx context.Context, container containerd.Container, newName string, options types.ContainerRenameOptions) error + + // Mocked functions for container attach + GetDataStore() (string, error) + LoggingInitContainerLogViewer(containerLabels map[string]string, lvopts logging.LogViewOptions, stopChannel chan os.Signal, experimental bool) (contlv *logging.ContainerLogViewer, err error) + LoggingPrintLogsTo(stdout, stderr io.Writer, clv *logging.ContainerLogViewer) error + + // GetNerdctlExe returns a path to the nerdctl binary, which is required for setting up OCI hooks and logging + GetNerdctlExe() (string, error) +} + +//go:generate mockgen --destination=../mocks/mocks_backend/nerdctlnetworksvc.go -package=mocks_backend github.com/runfinch/finch-daemon/pkg/backend NerdctlNetworkSvc +type NerdctlNetworkSvc interface { + FilterNetworks(filterf func(networkConfig *netutil.NetworkConfig) bool) ([]*netutil.NetworkConfig, error) + AddNetworkList(ctx context.Context, netconflist *libcni.NetworkConfigList, conf *libcni.RuntimeConf) (cnitypes.Result, error) + CreateNetwork(opts netutil.CreateOptions) (*netutil.NetworkConfig, error) + RemoveNetwork(networkConfig *netutil.NetworkConfig) error + InspectNetwork(ctx context.Context, networkConfig *netutil.NetworkConfig) (*dockercompat.Network, error) + UsedNetworkInfo(ctx context.Context) (map[string][]string, error) + NetconfPath() string +} + +//go:generate mockgen --destination=../mocks/mocks_backend/nerdctlvolumesvc.go -package=mocks_backend github.com/runfinch/finch-daemon/pkg/backend NerdctlVolumeSvc +type NerdctlVolumeSvc interface { + ListVolumes(size bool, filters []string) (map[string]native.Volume, error) + RemoveVolume(ctx context.Context, name string, force bool, stdout io.Writer) error + GetVolume(name string) (*native.Volume, error) + CreateVolume(name string, labels []string) (*native.Volume, error) +} + +//go:generate mockgen --destination=../mocks/mocks_backend/nerdctlsystemsvc.go -package=mocks_backend github.com/runfinch/finch-daemon/pkg/backend NerdctlSystemSvc +type NerdctlSystemSvc interface { + GetServerVersion(ctx context.Context) (*dockercompat.ServerVersion, error) +} + +type NerdctlWrapper struct { + clientWrapper *ContainerdClientWrapper + globalOptions *types.GlobalCommandOptions + nerdctlExe string + netClient *netutil.CNIEnv + CNI *libcni.CNIConfig +} + +func NewNerdctlWrapper(clientWrapper *ContainerdClientWrapper, options *types.GlobalCommandOptions) *NerdctlWrapper { + return &NerdctlWrapper{ + clientWrapper: clientWrapper, + globalOptions: options, + netClient: &netutil.CNIEnv{ + Path: options.CNIPath, + NetconfPath: options.CNINetConfPath, + }, + CNI: libcni.NewCNIConfig( + []string{ + options.CNIPath, + }, + &invoke.DefaultExec{ + RawExec: &invoke.RawExec{Stderr: os.Stderr}, + PluginDecoder: version.PluginDecoder{}, + }), + } +} + +func (w *NerdctlWrapper) GetNerdctlExe() (string, error) { + if w.nerdctlExe != "" { + return w.nerdctlExe, nil + } + exe, err := exec.LookPath("nerdctl") + if err != nil { + return "", err + } + w.nerdctlExe = exe + return exe, nil +} + +func (w *NerdctlWrapper) CreateVolume(name string, labels []string) (*native.Volume, error) { + volumeCreateOpts := types.VolumeCreateOptions{ + Stdout: os.Stdout, + Labels: labels, + GOptions: *w.globalOptions, + } + return volume.Create(name, volumeCreateOpts) +} + +func (w *NerdctlWrapper) ListVolumes(size bool, filters []string) (map[string]native.Volume, error) { + vols, err := volume.Volumes( + w.globalOptions.Namespace, + w.globalOptions.DataRoot, + w.globalOptions.Address, + size, + filters, + ) + if err != nil { + return nil, err + } + return vols, err +} + +// GetVolume wrapper function to call nerdctl function to get the details of a volume +func (w *NerdctlWrapper) GetVolume(name string) (*native.Volume, error) { + volStore, err := volume.Store(w.globalOptions.Namespace, w.globalOptions.DataRoot, w.globalOptions.Address) + if err != nil { + return nil, err + } + vols, err := volStore.Get(name, false) + if err != nil { + return nil, err + } + return vols, err +} + +// RemoveVolume wrapper function to call nerdctl function to remove a volume +func (w *NerdctlWrapper) RemoveVolume(ctx context.Context, name string, force bool, stdout io.Writer) error { + return volume.Remove( + ctx, + w.clientWrapper.client, + []string{name}, + types.VolumeRemoveOptions{ + Stdout: stdout, + GOptions: *w.globalOptions, + Force: force, + }) +} + +func (w *NerdctlWrapper) InspectImage(ctx context.Context, image images.Image) (*dockercompat.Image, error) { + n, err := imageinspector.Inspect(ctx, w.clientWrapper.client, image, w.globalOptions.Snapshotter) + if err != nil { + return nil, err + } + return dockercompat.ImageFromNative(n) +} + +// GetDockerResolver returns a new Docker config resolver from the reference host and auth credentials +func (w *NerdctlWrapper) GetDockerResolver(ctx context.Context, refDomain string, creds dockerconfigresolver.AuthCreds) (remotes.Resolver, docker.StatusTracker, error) { + dOpts := []dockerconfigresolver.Opt{dockerconfigresolver.WithHostsDirs(w.globalOptions.HostsDir)} + if creds != nil { + dOpts = append(dOpts, dockerconfigresolver.WithAuthCreds(creds)) + } + + hostOpts, err := dockerconfigresolver.NewHostOptions(ctx, refDomain, dOpts...) + if err != nil { + return nil, nil, err + } + + tracker := docker.NewInMemoryTracker() + resolverOpts := docker.ResolverOptions{ + Tracker: tracker, + Hosts: dockerconfig.ConfigureHosts(ctx, *hostOpts), + } + + return docker.NewResolver(resolverOpts), tracker, nil +} + +// PullImage pulls an image from nerdctl's imgutil library +func (w *NerdctlWrapper) PullImage(ctx context.Context, stdout, stderr io.Writer, resolver remotes.Resolver, ref string, platforms []ocispec.Platform) (*imgutil.EnsuredImage, error) { + return imgutil.PullImage( + ctx, + w.clientWrapper.client, + stdout, stderr, + w.globalOptions.Snapshotter, + resolver, + ref, + platforms, + nil, + false, + imgutil.RemoteSnapshotterFlags{}, + ) +} + +// PushImage pushes an image using nerdctl's imgutil library +func (w *NerdctlWrapper) PushImage(ctx context.Context, resolver remotes.Resolver, tracker docker.StatusTracker, stdout io.Writer, pushRef, ref string, platMC platforms.MatchComparer) error { + return push.Push( + ctx, + w.clientWrapper.client, + resolver, + tracker, + stdout, + pushRef, ref, + platMC, + false, + false, + ) +} + +func (w *NerdctlWrapper) SearchImage(ctx context.Context, name string) (int, int, []*images.Image, error) { + uniqueCount := 0 + var imgs []*images.Image + walker := &imagewalker.ImageWalker{ + Client: w.clientWrapper.GetClient(), + OnFound: func(ctx context.Context, found imagewalker.Found) error { + uniqueCount = found.UniqueImages + imgs = append(imgs, &found.Image) + return nil + }, + } + n, err := walker.Walk(ctx, name) + return n, uniqueCount, imgs, err +} + +func (w *NerdctlWrapper) RemoveContainer(ctx context.Context, c containerd.Container, force bool, removeVolumes bool) error { + return container.RemoveContainer(ctx, c, *w.globalOptions, force, removeVolumes, w.clientWrapper.client) +} + +// StartContainer wrapper function to call nerdctl function to start a container +func (w *NerdctlWrapper) StartContainer(ctx context.Context, container containerd.Container) error { + return containerutil.Start(ctx, container, false, w.clientWrapper.client, "") +} + +// StopContainer wrapper function to call nerdctl function to stop a container +func (*NerdctlWrapper) StopContainer(ctx context.Context, container containerd.Container, timeout *time.Duration) error { + return containerutil.Stop(ctx, container, timeout) +} + +func (*NerdctlWrapper) Build(ctx context.Context, client ContainerdClient, options types.BuilderBuildOptions) error { + return builder.Build(ctx, client.GetClient(), options) +} + +func (w *NerdctlWrapper) GetBuildkitHost() (string, error) { + return buildkitutil.GetBuildkitHost(w.globalOptions.Namespace) +} + +func (w *NerdctlWrapper) CreateContainer(ctx context.Context, args []string, netManager containerutil.NetworkOptionsManager, options types.ContainerCreateOptions) (containerd.Container, func(), error) { + return container.Create(ctx, w.clientWrapper.client, args, netManager, options) +} + +func (w *NerdctlWrapper) InspectContainer(ctx context.Context, c containerd.Container) (*dockercompat.Container, error) { + n, err := containerinspector.Inspect(ctx, c) + if err != nil { + return nil, err + } + return dockercompat.ContainerFromNative(n) +} + +func (w *NerdctlWrapper) InspectNetNS(ctx context.Context, pid int) (*native.NetNS, error) { + return containerinspector.InspectNetNS(ctx, pid) +} + +func (w *NerdctlWrapper) NewNetworkingOptionsManager(options types.NetworkOptions) (containerutil.NetworkOptionsManager, error) { + return containerutil.NewNetworkingOptionsManager(*w.globalOptions, options, w.clientWrapper.client) +} + +func (w *NerdctlWrapper) FilterNetworks(filterf func(networkConfig *netutil.NetworkConfig) bool) ([]*netutil.NetworkConfig, error) { + return w.netClient.FilterNetworks(filterf) +} + +func (w *NerdctlWrapper) AddNetworkList(ctx context.Context, netconflist *libcni.NetworkConfigList, conf *libcni.RuntimeConf) (cnitypes.Result, error) { + return w.CNI.AddNetworkList(ctx, netconflist, conf) +} + +func (w *NerdctlWrapper) CreateNetwork(opts netutil.CreateOptions) (*netutil.NetworkConfig, error) { + return w.netClient.CreateNetwork(opts) +} + +func (w *NerdctlWrapper) RemoveNetwork(networkConfig *netutil.NetworkConfig) error { + return w.netClient.RemoveNetwork(networkConfig) +} + +func (w *NerdctlWrapper) InspectNetwork(ctx context.Context, networkConfig *netutil.NetworkConfig) (*dockercompat.Network, error) { + network := &native.Network{ + CNI: json.RawMessage(networkConfig.Bytes), + NerdctlID: networkConfig.NerdctlID, + NerdctlLabels: networkConfig.NerdctlLabels, + File: networkConfig.File, + } + return dockercompat.NetworkFromNative(network) +} + +func (w *NerdctlWrapper) UsedNetworkInfo(ctx context.Context) (map[string][]string, error) { + return netutil.UsedNetworks(ctx, w.clientWrapper.client) +} + +func (w *NerdctlWrapper) NetconfPath() string { + return w.netClient.NetconfPath +} + +func (w *NerdctlWrapper) GetDataStore() (string, error) { + return clientutil.DataStore(w.globalOptions.DataRoot, w.globalOptions.Address) +} + +func (*NerdctlWrapper) LoggingInitContainerLogViewer(containerLabels map[string]string, lvopts logging.LogViewOptions, stopChannel chan os.Signal, experimental bool) (contlv *logging.ContainerLogViewer, err error) { + return logging.InitContainerLogViewer(containerLabels, lvopts, stopChannel, experimental) +} + +func (*NerdctlWrapper) LoggingPrintLogsTo(stdout, stderr io.Writer, clv *logging.ContainerLogViewer) error { + return clv.PrintLogsTo(stdout, stderr) +} + +func (w *NerdctlWrapper) ListContainers(ctx context.Context, options types.ContainerListOptions) ([]container.ListItem, error) { + return container.List(ctx, w.clientWrapper.client, options) +} + +func (w *NerdctlWrapper) RenameContainer(ctx context.Context, con containerd.Container, newName string, options types.ContainerRenameOptions) error { + return container.Rename(ctx, w.clientWrapper.client, con.ID(), newName, options) +} + +func (w *NerdctlWrapper) GetServerVersion(ctx context.Context) (*dockercompat.ServerVersion, error) { + return infoutil.ServerVersion(ctx, w.clientWrapper.GetClient()) +} + +//go:generate mockgen --destination=../mocks/mocks_backend/containerdclient.go -package=mocks_backend github.com/runfinch/finch-daemon/pkg/backend ContainerdClient +type ContainerdClient interface { + GetClient() *containerd.Client + GetContainerStatus(ctx context.Context, c containerd.Container) containerd.ProcessStatus + SearchContainer(ctx context.Context, searchText string) (containers []containerd.Container, err error) + GetImage(ctx context.Context, ref string) (containerd.Image, error) + SearchImage(ctx context.Context, searchText string) ([]images.Image, error) + ParsePlatform(platform string) (ocispec.Platform, error) + DefaultPlatformSpec() ocispec.Platform + DefaultPlatformStrict() platforms.MatchComparer + ParseDockerRef(rawRef string) (ref, refDomain string, err error) + DefaultDockerHost(refDomain string) (string, error) + GetContainerTaskWait(ctx context.Context, attach cio.Attach, c containerd.Container) (task containerd.Task, waitCh <-chan containerd.ExitStatus, err error) + GetContainerRemoveEvent(ctx context.Context, c containerd.Container) (<-chan *events.Envelope, <-chan error) + ListSnapshotMounts(ctx context.Context, cid string) ([]mount.Mount, error) + MountAll(mounts []mount.Mount, mPath string) error + Unmount(mPath string, flags int) error + ImageService() images.Store + ConvertImage(ctx context.Context, dstRef, srcRef string, opts ...converter.Opt) (*images.Image, error) + DeleteImage(ctx context.Context, img string) error + GetImageDigests(ctx context.Context, img *images.Image) (digests []digest.Digest, err error) + GetUsedImages(ctx context.Context) (stopped, running map[string]string, err error) + OCISpecWithUser(user string) oci.SpecOpts + OCISpecWithAdditionalGIDs(user string) oci.SpecOpts + GetCurrentCapabilities() ([]string, error) + NewFIFOSetInDir(root, id string, terminal bool) (*cio.FIFOSet, error) + NewDirectCIO(ctx context.Context, fifos *cio.FIFOSet) (*cio.DirectIO, error) + SubscribeToEvents(ctx context.Context, filters ...string) (<-chan *events.Envelope, <-chan error) + PublishEvent(ctx context.Context, topic string, event events.Event) error +} + +type ContainerdClientWrapper struct { + client *containerd.Client +} + +// NewContainerdClientWrapper creates a new instance of ContainerdClientWrapper +func NewContainerdClientWrapper(client *containerd.Client) *ContainerdClientWrapper { + return &ContainerdClientWrapper{ + client: client, + } +} + +func (w *ContainerdClientWrapper) GetClient() *containerd.Client { + return w.client +} + +// GetContainerStatus wraps the containerd function to get the status of a container +func (w *ContainerdClientWrapper) GetContainerStatus(ctx context.Context, c containerd.Container) containerd.ProcessStatus { + // Just in case, there is something wrong in server. + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + task, err := c.Task(ctx, nil) + if err != nil { + // NOTE: NotFound doesn't mean that container hasn't started. + // In docker/CRI-containerd plugin, the task will be deleted + // when it exits. So, the status will be "created" for this + // case. + if errdefs.IsNotFound(err) { + return containerd.Created + } + return containerd.Unknown + } + + status, err := task.Status(ctx) + if err != nil { + return containerd.Unknown + } + return status.Status +} + +// SearchContainer returns the list of containers that match the prefix +func (w *ContainerdClientWrapper) SearchContainer(ctx context.Context, searchText string) (containers []containerd.Container, err error) { + filters := []string{ + fmt.Sprintf("labels.%q==%s", labels.Name, searchText), + fmt.Sprintf("id~=^%s.*$", regexp.QuoteMeta(searchText)), + } + + containers, err = w.client.Containers(ctx, filters...) + return containers, err +} + +// GetImage returns an image with given reference +func (w *ContainerdClientWrapper) GetImage(ctx context.Context, ref string) (containerd.Image, error) { + return w.client.GetImage(ctx, ref) +} + +// SearchImage returns a list of images that match the search prefix +func (w *ContainerdClientWrapper) SearchImage(ctx context.Context, searchText string) ([]images.Image, error) { + var filters []string + if canonicalRef, err := referenceutil.ParseAny(searchText); err == nil { + filters = append(filters, fmt.Sprintf("name==%s", canonicalRef.String())) + } + filters = append(filters, + fmt.Sprintf("name==%s", searchText), + fmt.Sprintf("target.digest~=^sha256:%s.*$", regexp.QuoteMeta(searchText)), + fmt.Sprintf("target.digest~=^%s.*$", regexp.QuoteMeta(searchText)), + ) + + return w.client.ImageService().List(ctx, filters...) +} + +// ParsePlatform parses a platform text into an ocispec Platform type +func (*ContainerdClientWrapper) ParsePlatform(platform string) (ocispec.Platform, error) { + return platforms.Parse(platform) +} + +// DefaultPlatformSpec returns the current platform's default platform specification +func (w *ContainerdClientWrapper) DefaultPlatformSpec() ocispec.Platform { + return platforms.DefaultSpec() +} + +// DefaultPlatformStrict returns the strict form of current platform's default platform specification +func (w *ContainerdClientWrapper) DefaultPlatformStrict() platforms.MatchComparer { + return platforms.DefaultStrict() +} + +// ParseDockerRef normalizes the image reference following the docker convention +func (w *ContainerdClientWrapper) ParseDockerRef(rawRef string) (ref, refDomain string, err error) { + named, err := refdocker.ParseDockerRef(rawRef) + if err != nil { + return + } + ref = named.String() + refDomain = refdocker.Domain(named) + return +} + +// DefaultDockerHost converts "docker.io" to "registry-1.docker.io" +func (w *ContainerdClientWrapper) DefaultDockerHost(refDomain string) (string, error) { + return docker.DefaultHost(refDomain) +} + +// GetContainerTaskWait gets the wait channel for a container in the process of doing a task +func (*ContainerdClientWrapper) GetContainerTaskWait(ctx context.Context, attach cio.Attach, c containerd.Container) (task containerd.Task, waitCh <-chan containerd.ExitStatus, err error) { + task, err = c.Task(ctx, attach) + if err != nil { + waitCh = nil + return + } + waitCh, err = task.Wait(ctx) + return +} + +// GetContainerRemoveEvent subscribes to the remove event for the given container and returns its channel +func (w *ContainerdClientWrapper) GetContainerRemoveEvent(ctx context.Context, c containerd.Container) (<-chan *events.Envelope, <-chan error) { + return w.client.Subscribe(ctx, + fmt.Sprintf(`topic=="/containers/delete",event.id=="%s"`, c.ID()), + ) +} + +func (w *ContainerdClientWrapper) ListSnapshotMounts(ctx context.Context, key string) ([]mount.Mount, error) { + return w.client.SnapshotService("").Mounts(ctx, key) +} + +func (*ContainerdClientWrapper) MountAll(mounts []mount.Mount, mPath string) error { + return mount.All(mounts, mPath) +} + +func (*ContainerdClientWrapper) Unmount(mPath string, flags int) error { + return mount.Unmount(mPath, flags) +} + +func (w *ContainerdClientWrapper) ImageService() images.Store { + return w.client.ImageService() +} + +func (w *ContainerdClientWrapper) ConvertImage(ctx context.Context, dstRef, srcRef string, opts ...converter.Opt) (*images.Image, error) { + return converter.Convert(ctx, w.client, dstRef, srcRef, opts...) +} + +// DeleteImage deletes an image +func (w *ContainerdClientWrapper) DeleteImage(ctx context.Context, img string) error { + return w.client.ImageService().Delete(ctx, img, images.SynchronousDelete()) +} + +// GetImageDigests returns the list of digests for a given image +func (w *ContainerdClientWrapper) GetImageDigests(ctx context.Context, img *images.Image) (digests []digest.Digest, err error) { + cntStore := w.client.ContentStore() + return img.RootFS(ctx, cntStore, platforms.DefaultStrict()) +} + +// GetUsedImages returns the list of images that are used by containers. +// `stopped` contains the images used by stopped containers, `running` contains the images used by running containers. +func (w *ContainerdClientWrapper) GetUsedImages(ctx context.Context) (stopped, running map[string]string, err error) { + stopped = make(map[string]string) + running = make(map[string]string) + containerList, err := w.client.Containers(ctx) + if err != nil { + return + } + for _, cont := range containerList { + image, err := cont.Image(ctx) + // skip if the image is not found + if err != nil { + continue + } + switch cStatus, _ := containerutil.ContainerStatus(ctx, cont); cStatus.Status { + case containerd.Running, containerd.Pausing, containerd.Paused: + running[image.Name()] = cont.ID() + default: + stopped[image.Name()] = cont.ID() + } + } + return stopped, running, err +} + +func (*ContainerdClientWrapper) OCISpecWithUser(user string) oci.SpecOpts { + return oci.WithUser(user) +} + +func (*ContainerdClientWrapper) OCISpecWithAdditionalGIDs(user string) oci.SpecOpts { + return oci.WithAdditionalGIDs(user) +} + +func (*ContainerdClientWrapper) GetCurrentCapabilities() ([]string, error) { + return cap.Current() +} + +func (*ContainerdClientWrapper) NewFIFOSetInDir(root, id string, terminal bool) (*cio.FIFOSet, error) { + return cio.NewFIFOSetInDir(root, id, terminal) +} + +func (*ContainerdClientWrapper) NewDirectCIO(ctx context.Context, fifos *cio.FIFOSet) (*cio.DirectIO, error) { + return cio.NewDirectIO(ctx, fifos) +} + +func (c *ContainerdClientWrapper) SubscribeToEvents(ctx context.Context, filters ...string) (<-chan *events.Envelope, <-chan error) { + return c.client.EventService().Subscribe(ctx, filters...) +} + +func (c *ContainerdClientWrapper) PublishEvent(ctx context.Context, topic string, event events.Event) error { + return c.client.EventService().Publish(ctx, topic, event) +} diff --git a/pkg/ecc/ecc.go b/pkg/ecc/ecc.go new file mode 100644 index 00000000..faf93252 --- /dev/null +++ b/pkg/ecc/ecc.go @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package ecc + +import ( + "io" + "os/exec" +) + +//go:generate mockgen --destination=../mocks/mocks_ecc/execcmdcreator.go -package=mocks_ecc github.com/runfinch/finch-daemon/pkg/ecc ExecCmdCreator +type ExecCmdCreator interface { + Command(name string, args ...string) ExecCmd +} + +//go:generate mockgen --destination=../mocks/mocks_ecc/execcmd.go -package=mocks_ecc github.com/runfinch/finch-daemon/pkg/ecc ExecCmd +type ExecCmd interface { + Run() error + SetDir(path string) + SetStdout(writer io.Writer) + SetStderr(writer io.Writer) + SetStdin(reader io.Reader) + GetDir() string +} + +func NewExecCmdCreator() ExecCmdCreator { + return &execCmdCreator{} +} + +type execCmdCreator struct { +} + +func (*execCmdCreator) Command(name string, args ...string) ExecCmd { + return &execCmd{ + cmd: exec.Command(name, args...), + } +} + +type execCmd struct { + cmd *exec.Cmd +} + +// Run runs the command +func (c *execCmd) Run() error { + return c.cmd.Run() +} + +// SetDir sets the command's working directory +func (c *execCmd) SetDir(path string) { + c.cmd.Dir = path +} + +// SetStdout sets the command's standard output. +func (c *execCmd) SetStdout(writer io.Writer) { + c.cmd.Stdout = writer +} + +// SetStderr sets the command's standard error output. +func (c *execCmd) SetStderr(writer io.Writer) { + c.cmd.Stderr = writer +} + +// SetStdin sets the command's standard input. +func (c *execCmd) SetStdin(reader io.Reader) { + c.cmd.Stdin = reader +} + +// GetDir gets the command's working directory +func (c *execCmd) GetDir() string { + return c.cmd.Dir +} diff --git a/pkg/errdefs/errdefs.go b/pkg/errdefs/errdefs.go new file mode 100644 index 00000000..f1e555f2 --- /dev/null +++ b/pkg/errdefs/errdefs.go @@ -0,0 +1,110 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package errdefs contains the error definitions (i.e., domain types). +// It is created to prevent adaptor layers (e.g., HTTP handler) from +// directly depending on the errors received by infra layers (e.g., containerd's error). +package errdefs + +import ( + "github.com/pkg/errors" +) + +type errType int + +const ( + unknown errType = iota + unauthenticated + notFound + invalidFormat + conflict + notModified + wrongSemantics + forbidden +) + +type errWithType struct { + t errType + wrapped error +} + +var _ error = &errWithType{} + +func (e *errWithType) Error() string { + return e.wrapped.Error() +} + +func (e *errWithType) Unwrap() error { + return e.wrapped +} + +func new(t errType, err error) error { + return &errWithType{ + t: t, + wrapped: err, + } +} + +// isType only checks the first errWithType in the error chain. +func isType(t errType, err error) bool { + var ewt *errWithType + if errors.As(err, &ewt) { + return ewt.t == t + } + return false +} + +func NewUnauthenticated(err error) error { + return new(unauthenticated, err) +} + +func IsUnauthenticated(err error) bool { + return isType(unauthenticated, err) +} + +func NewNotFound(err error) error { + return new(notFound, err) +} + +func IsNotFound(err error) bool { + return isType(notFound, err) +} + +func NewInvalidFormat(err error) error { + return new(invalidFormat, err) +} + +func IsInvalidFormat(err error) bool { + return isType(invalidFormat, err) +} + +func NewConflict(err error) error { + return new(conflict, err) +} + +func IsConflict(err error) bool { + return isType(conflict, err) +} + +func NewNotModified(err error) error { + return new(notModified, err) +} +func IsNotModified(err error) bool { + return isType(notModified, err) +} + +func NewWrongSemantics(err error) error { + return new(wrongSemantics, err) +} + +func IsWrongSemantics(err error) bool { + return isType(wrongSemantics, err) +} + +func NewForbidden(err error) error { + return new(forbidden, err) +} + +func IsForbiddenError(err error) bool { + return isType(forbidden, err) +} diff --git a/pkg/flog/level_string.go b/pkg/flog/level_string.go new file mode 100644 index 00000000..828c5024 --- /dev/null +++ b/pkg/flog/level_string.go @@ -0,0 +1,24 @@ +// Code generated by "stringer -type=Level"; DO NOT EDIT. + +package flog + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[Debug-0] + _ = x[Panic-1] +} + +const _Level_name = "DebugPanic" + +var _Level_index = [...]uint8{0, 5, 10} + +func (i Level) String() string { + if i < 0 || i >= Level(len(_Level_index)-1) { + return "Level(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Level_name[_Level_index[i]:_Level_index[i+1]] +} diff --git a/pkg/flog/log.go b/pkg/flog/log.go new file mode 100644 index 00000000..00e48363 --- /dev/null +++ b/pkg/flog/log.go @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package flog contains logging-related APIs. +package flog + +// Logger should be used to write any logs. No concrete implementations should be directly used. +// +//go:generate mockgen -destination=../mocks/mocks_logger/logger.go -package=mocks_logger -mock_names Logger=Logger . Logger +type Logger interface { + Debugf(format string, args ...interface{}) + Debugln(args ...interface{}) + Info(args ...interface{}) + Infof(format string, args ...interface{}) + Infoln(args ...interface{}) + Warn(args ...interface{}) + Warnf(format string, args ...interface{}) + Warnln(args ...interface{}) + Error(args ...interface{}) + Errorf(format string, args ...interface{}) + Fatal(args ...interface{}) + SetLevel(level Level) +} + +// Level denotes a log level. Check the constants below for more information. +type Level int + +const ( + Debug Level = iota + Panic +) diff --git a/pkg/flog/logrus.go b/pkg/flog/logrus.go new file mode 100644 index 00000000..eb7e8560 --- /dev/null +++ b/pkg/flog/logrus.go @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package flog + +import "github.com/sirupsen/logrus" + +// Logrus implements the Logger interface. +type Logrus struct{} + +var _ Logger = (*Logrus)(nil) + +// NewLogrus returns a new logrus logger. +func NewLogrus() *Logrus { + return &Logrus{} +} + +// Debugf logs a message at level Debug. +func (l *Logrus) Debugf(format string, args ...interface{}) { + logrus.Debugf(format, args...) +} + +// Debugln logs a message at level Debug. +func (l *Logrus) Debugln(args ...interface{}) { + logrus.Debugln(args...) +} + +// Info logs a message at level Info. +func (l *Logrus) Info(args ...interface{}) { + logrus.Info(args...) +} + +// Infof logs a message at level Info. +func (l *Logrus) Infof(format string, args ...interface{}) { + logrus.Infof(format, args...) +} + +// Infoln logs a message at level Info. +func (l *Logrus) Infoln(args ...interface{}) { + logrus.Infoln(args...) +} + +func (l *Logrus) Warn(args ...interface{}) { + logrus.Warn(args...) +} + +// Warnln logs a message at level Warn. +func (l *Logrus) Warnln(args ...interface{}) { + logrus.Warnln(args...) +} + +// Warnf logs a message at level Warn. +func (l *Logrus) Warnf(format string, args ...interface{}) { + logrus.Warnf(format, args...) +} + +// Error logs a message at level Error. +func (l *Logrus) Error(args ...interface{}) { + logrus.Error(args...) +} + +// Errorf logs a message at level Error. +func (l *Logrus) Errorf(format string, args ...interface{}) { + logrus.Errorf(format, args...) +} + +// Fatal logs a message at level Fatal. +func (l *Logrus) Fatal(args ...interface{}) { + logrus.Fatal(args...) +} + +// SetLevel sets the level of the logger. +func (l *Logrus) SetLevel(level Level) { + switch level { + case Debug: + logrus.SetLevel(logrus.DebugLevel) + case Panic: + logrus.SetLevel(logrus.PanicLevel) + } +} diff --git a/pkg/mocks/mocks_archive/tarcreator.go b/pkg/mocks/mocks_archive/tarcreator.go new file mode 100644 index 00000000..0bee8415 --- /dev/null +++ b/pkg/mocks/mocks_archive/tarcreator.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/archive (interfaces: TarCreator) + +// Package mocks_archive is a generated GoMock package. +package mocks_archive + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + ecc "github.com/runfinch/finch-daemon/pkg/ecc" +) + +// MockTarCreator is a mock of TarCreator interface. +type MockTarCreator struct { + ctrl *gomock.Controller + recorder *MockTarCreatorMockRecorder +} + +// MockTarCreatorMockRecorder is the mock recorder for MockTarCreator. +type MockTarCreatorMockRecorder struct { + mock *MockTarCreator +} + +// NewMockTarCreator creates a new mock instance. +func NewMockTarCreator(ctrl *gomock.Controller) *MockTarCreator { + mock := &MockTarCreator{ctrl: ctrl} + mock.recorder = &MockTarCreatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTarCreator) EXPECT() *MockTarCreatorMockRecorder { + return m.recorder +} + +// CreateTarCommand mocks base method. +func (m *MockTarCreator) CreateTarCommand(arg0 string, arg1 bool) (ecc.ExecCmd, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTarCommand", arg0, arg1) + ret0, _ := ret[0].(ecc.ExecCmd) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateTarCommand indicates an expected call of CreateTarCommand. +func (mr *MockTarCreatorMockRecorder) CreateTarCommand(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTarCommand", reflect.TypeOf((*MockTarCreator)(nil).CreateTarCommand), arg0, arg1) +} diff --git a/pkg/mocks/mocks_archive/tarextractor.go b/pkg/mocks/mocks_archive/tarextractor.go new file mode 100644 index 00000000..a0728eba --- /dev/null +++ b/pkg/mocks/mocks_archive/tarextractor.go @@ -0,0 +1,93 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/archive (interfaces: TarExtractor) + +// Package mocks_archive is a generated GoMock package. +package mocks_archive + +import ( + io "io" + reflect "reflect" + + archive "github.com/docker/docker/pkg/archive" + gomock "github.com/golang/mock/gomock" + ecc "github.com/runfinch/finch-daemon/pkg/ecc" +) + +// MockTarExtractor is a mock of TarExtractor interface. +type MockTarExtractor struct { + ctrl *gomock.Controller + recorder *MockTarExtractorMockRecorder +} + +// MockTarExtractorMockRecorder is the mock recorder for MockTarExtractor. +type MockTarExtractorMockRecorder struct { + mock *MockTarExtractor +} + +// NewMockTarExtractor creates a new mock instance. +func NewMockTarExtractor(ctrl *gomock.Controller) *MockTarExtractor { + mock := &MockTarExtractor{ctrl: ctrl} + mock.recorder = &MockTarExtractorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTarExtractor) EXPECT() *MockTarExtractorMockRecorder { + return m.recorder +} + +// Cleanup mocks base method. +func (m *MockTarExtractor) Cleanup(arg0 ecc.ExecCmd) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Cleanup", arg0) +} + +// Cleanup indicates an expected call of Cleanup. +func (mr *MockTarExtractorMockRecorder) Cleanup(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cleanup", reflect.TypeOf((*MockTarExtractor)(nil).Cleanup), arg0) +} + +// CreateExtractCmd mocks base method. +func (m *MockTarExtractor) CreateExtractCmd(arg0 io.Reader, arg1 string) (ecc.ExecCmd, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateExtractCmd", arg0, arg1) + ret0, _ := ret[0].(ecc.ExecCmd) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateExtractCmd indicates an expected call of CreateExtractCmd. +func (mr *MockTarExtractorMockRecorder) CreateExtractCmd(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateExtractCmd", reflect.TypeOf((*MockTarExtractor)(nil).CreateExtractCmd), arg0, arg1) +} + +// ExtractCompressed mocks base method. +func (m *MockTarExtractor) ExtractCompressed(arg0 io.Reader, arg1 string, arg2 *archive.TarOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExtractCompressed", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// ExtractCompressed indicates an expected call of ExtractCompressed. +func (mr *MockTarExtractorMockRecorder) ExtractCompressed(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractCompressed", reflect.TypeOf((*MockTarExtractor)(nil).ExtractCompressed), arg0, arg1, arg2) +} + +// ExtractInTemp mocks base method. +func (m *MockTarExtractor) ExtractInTemp(arg0 io.Reader, arg1 string) (ecc.ExecCmd, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExtractInTemp", arg0, arg1) + ret0, _ := ret[0].(ecc.ExecCmd) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExtractInTemp indicates an expected call of ExtractInTemp. +func (mr *MockTarExtractorMockRecorder) ExtractInTemp(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractInTemp", reflect.TypeOf((*MockTarExtractor)(nil).ExtractInTemp), arg0, arg1) +} diff --git a/pkg/mocks/mocks_backend/containerdclient.go b/pkg/mocks/mocks_backend/containerdclient.go new file mode 100644 index 00000000..0f9729c9 --- /dev/null +++ b/pkg/mocks/mocks_backend/containerdclient.go @@ -0,0 +1,452 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/backend (interfaces: ContainerdClient) + +// Package mocks_backend is a generated GoMock package. +package mocks_backend + +import ( + context "context" + reflect "reflect" + + containerd "github.com/containerd/containerd" + cio "github.com/containerd/containerd/cio" + events "github.com/containerd/containerd/events" + images "github.com/containerd/containerd/images" + converter "github.com/containerd/containerd/images/converter" + mount "github.com/containerd/containerd/mount" + oci "github.com/containerd/containerd/oci" + platforms "github.com/containerd/containerd/platforms" + gomock "github.com/golang/mock/gomock" + digest "github.com/opencontainers/go-digest" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +// MockContainerdClient is a mock of ContainerdClient interface. +type MockContainerdClient struct { + ctrl *gomock.Controller + recorder *MockContainerdClientMockRecorder +} + +// MockContainerdClientMockRecorder is the mock recorder for MockContainerdClient. +type MockContainerdClientMockRecorder struct { + mock *MockContainerdClient +} + +// NewMockContainerdClient creates a new mock instance. +func NewMockContainerdClient(ctrl *gomock.Controller) *MockContainerdClient { + mock := &MockContainerdClient{ctrl: ctrl} + mock.recorder = &MockContainerdClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockContainerdClient) EXPECT() *MockContainerdClientMockRecorder { + return m.recorder +} + +// ConvertImage mocks base method. +func (m *MockContainerdClient) ConvertImage(arg0 context.Context, arg1, arg2 string, arg3 ...converter.Opt) (*images.Image, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ConvertImage", varargs...) + ret0, _ := ret[0].(*images.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConvertImage indicates an expected call of ConvertImage. +func (mr *MockContainerdClientMockRecorder) ConvertImage(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConvertImage", reflect.TypeOf((*MockContainerdClient)(nil).ConvertImage), varargs...) +} + +// DefaultDockerHost mocks base method. +func (m *MockContainerdClient) DefaultDockerHost(arg0 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DefaultDockerHost", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DefaultDockerHost indicates an expected call of DefaultDockerHost. +func (mr *MockContainerdClientMockRecorder) DefaultDockerHost(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DefaultDockerHost", reflect.TypeOf((*MockContainerdClient)(nil).DefaultDockerHost), arg0) +} + +// DefaultPlatformSpec mocks base method. +func (m *MockContainerdClient) DefaultPlatformSpec() v1.Platform { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DefaultPlatformSpec") + ret0, _ := ret[0].(v1.Platform) + return ret0 +} + +// DefaultPlatformSpec indicates an expected call of DefaultPlatformSpec. +func (mr *MockContainerdClientMockRecorder) DefaultPlatformSpec() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DefaultPlatformSpec", reflect.TypeOf((*MockContainerdClient)(nil).DefaultPlatformSpec)) +} + +// DefaultPlatformStrict mocks base method. +func (m *MockContainerdClient) DefaultPlatformStrict() platforms.MatchComparer { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DefaultPlatformStrict") + ret0, _ := ret[0].(platforms.MatchComparer) + return ret0 +} + +// DefaultPlatformStrict indicates an expected call of DefaultPlatformStrict. +func (mr *MockContainerdClientMockRecorder) DefaultPlatformStrict() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DefaultPlatformStrict", reflect.TypeOf((*MockContainerdClient)(nil).DefaultPlatformStrict)) +} + +// DeleteImage mocks base method. +func (m *MockContainerdClient) DeleteImage(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteImage", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteImage indicates an expected call of DeleteImage. +func (mr *MockContainerdClientMockRecorder) DeleteImage(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteImage", reflect.TypeOf((*MockContainerdClient)(nil).DeleteImage), arg0, arg1) +} + +// GetClient mocks base method. +func (m *MockContainerdClient) GetClient() *containerd.Client { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClient") + ret0, _ := ret[0].(*containerd.Client) + return ret0 +} + +// GetClient indicates an expected call of GetClient. +func (mr *MockContainerdClientMockRecorder) GetClient() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClient", reflect.TypeOf((*MockContainerdClient)(nil).GetClient)) +} + +// GetContainerRemoveEvent mocks base method. +func (m *MockContainerdClient) GetContainerRemoveEvent(arg0 context.Context, arg1 containerd.Container) (<-chan *events.Envelope, <-chan error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContainerRemoveEvent", arg0, arg1) + ret0, _ := ret[0].(<-chan *events.Envelope) + ret1, _ := ret[1].(<-chan error) + return ret0, ret1 +} + +// GetContainerRemoveEvent indicates an expected call of GetContainerRemoveEvent. +func (mr *MockContainerdClientMockRecorder) GetContainerRemoveEvent(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContainerRemoveEvent", reflect.TypeOf((*MockContainerdClient)(nil).GetContainerRemoveEvent), arg0, arg1) +} + +// GetContainerStatus mocks base method. +func (m *MockContainerdClient) GetContainerStatus(arg0 context.Context, arg1 containerd.Container) containerd.ProcessStatus { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContainerStatus", arg0, arg1) + ret0, _ := ret[0].(containerd.ProcessStatus) + return ret0 +} + +// GetContainerStatus indicates an expected call of GetContainerStatus. +func (mr *MockContainerdClientMockRecorder) GetContainerStatus(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContainerStatus", reflect.TypeOf((*MockContainerdClient)(nil).GetContainerStatus), arg0, arg1) +} + +// GetContainerTaskWait mocks base method. +func (m *MockContainerdClient) GetContainerTaskWait(arg0 context.Context, arg1 cio.Attach, arg2 containerd.Container) (containerd.Task, <-chan containerd.ExitStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContainerTaskWait", arg0, arg1, arg2) + ret0, _ := ret[0].(containerd.Task) + ret1, _ := ret[1].(<-chan containerd.ExitStatus) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetContainerTaskWait indicates an expected call of GetContainerTaskWait. +func (mr *MockContainerdClientMockRecorder) GetContainerTaskWait(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContainerTaskWait", reflect.TypeOf((*MockContainerdClient)(nil).GetContainerTaskWait), arg0, arg1, arg2) +} + +// GetCurrentCapabilities mocks base method. +func (m *MockContainerdClient) GetCurrentCapabilities() ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCurrentCapabilities") + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCurrentCapabilities indicates an expected call of GetCurrentCapabilities. +func (mr *MockContainerdClientMockRecorder) GetCurrentCapabilities() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentCapabilities", reflect.TypeOf((*MockContainerdClient)(nil).GetCurrentCapabilities)) +} + +// GetImage mocks base method. +func (m *MockContainerdClient) GetImage(arg0 context.Context, arg1 string) (containerd.Image, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetImage", arg0, arg1) + ret0, _ := ret[0].(containerd.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetImage indicates an expected call of GetImage. +func (mr *MockContainerdClientMockRecorder) GetImage(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetImage", reflect.TypeOf((*MockContainerdClient)(nil).GetImage), arg0, arg1) +} + +// GetImageDigests mocks base method. +func (m *MockContainerdClient) GetImageDigests(arg0 context.Context, arg1 *images.Image) ([]digest.Digest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetImageDigests", arg0, arg1) + ret0, _ := ret[0].([]digest.Digest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetImageDigests indicates an expected call of GetImageDigests. +func (mr *MockContainerdClientMockRecorder) GetImageDigests(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetImageDigests", reflect.TypeOf((*MockContainerdClient)(nil).GetImageDigests), arg0, arg1) +} + +// GetUsedImages mocks base method. +func (m *MockContainerdClient) GetUsedImages(arg0 context.Context) (map[string]string, map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsedImages", arg0) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(map[string]string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUsedImages indicates an expected call of GetUsedImages. +func (mr *MockContainerdClientMockRecorder) GetUsedImages(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsedImages", reflect.TypeOf((*MockContainerdClient)(nil).GetUsedImages), arg0) +} + +// ImageService mocks base method. +func (m *MockContainerdClient) ImageService() images.Store { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ImageService") + ret0, _ := ret[0].(images.Store) + return ret0 +} + +// ImageService indicates an expected call of ImageService. +func (mr *MockContainerdClientMockRecorder) ImageService() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImageService", reflect.TypeOf((*MockContainerdClient)(nil).ImageService)) +} + +// ListSnapshotMounts mocks base method. +func (m *MockContainerdClient) ListSnapshotMounts(arg0 context.Context, arg1 string) ([]mount.Mount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListSnapshotMounts", arg0, arg1) + ret0, _ := ret[0].([]mount.Mount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListSnapshotMounts indicates an expected call of ListSnapshotMounts. +func (mr *MockContainerdClientMockRecorder) ListSnapshotMounts(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSnapshotMounts", reflect.TypeOf((*MockContainerdClient)(nil).ListSnapshotMounts), arg0, arg1) +} + +// MountAll mocks base method. +func (m *MockContainerdClient) MountAll(arg0 []mount.Mount, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MountAll", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// MountAll indicates an expected call of MountAll. +func (mr *MockContainerdClientMockRecorder) MountAll(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MountAll", reflect.TypeOf((*MockContainerdClient)(nil).MountAll), arg0, arg1) +} + +// NewDirectCIO mocks base method. +func (m *MockContainerdClient) NewDirectCIO(arg0 context.Context, arg1 *cio.FIFOSet) (*cio.DirectIO, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewDirectCIO", arg0, arg1) + ret0, _ := ret[0].(*cio.DirectIO) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewDirectCIO indicates an expected call of NewDirectCIO. +func (mr *MockContainerdClientMockRecorder) NewDirectCIO(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewDirectCIO", reflect.TypeOf((*MockContainerdClient)(nil).NewDirectCIO), arg0, arg1) +} + +// NewFIFOSetInDir mocks base method. +func (m *MockContainerdClient) NewFIFOSetInDir(arg0, arg1 string, arg2 bool) (*cio.FIFOSet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewFIFOSetInDir", arg0, arg1, arg2) + ret0, _ := ret[0].(*cio.FIFOSet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewFIFOSetInDir indicates an expected call of NewFIFOSetInDir. +func (mr *MockContainerdClientMockRecorder) NewFIFOSetInDir(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewFIFOSetInDir", reflect.TypeOf((*MockContainerdClient)(nil).NewFIFOSetInDir), arg0, arg1, arg2) +} + +// OCISpecWithAdditionalGIDs mocks base method. +func (m *MockContainerdClient) OCISpecWithAdditionalGIDs(arg0 string) oci.SpecOpts { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OCISpecWithAdditionalGIDs", arg0) + ret0, _ := ret[0].(oci.SpecOpts) + return ret0 +} + +// OCISpecWithAdditionalGIDs indicates an expected call of OCISpecWithAdditionalGIDs. +func (mr *MockContainerdClientMockRecorder) OCISpecWithAdditionalGIDs(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OCISpecWithAdditionalGIDs", reflect.TypeOf((*MockContainerdClient)(nil).OCISpecWithAdditionalGIDs), arg0) +} + +// OCISpecWithUser mocks base method. +func (m *MockContainerdClient) OCISpecWithUser(arg0 string) oci.SpecOpts { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OCISpecWithUser", arg0) + ret0, _ := ret[0].(oci.SpecOpts) + return ret0 +} + +// OCISpecWithUser indicates an expected call of OCISpecWithUser. +func (mr *MockContainerdClientMockRecorder) OCISpecWithUser(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OCISpecWithUser", reflect.TypeOf((*MockContainerdClient)(nil).OCISpecWithUser), arg0) +} + +// ParseDockerRef mocks base method. +func (m *MockContainerdClient) ParseDockerRef(arg0 string) (string, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ParseDockerRef", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ParseDockerRef indicates an expected call of ParseDockerRef. +func (mr *MockContainerdClientMockRecorder) ParseDockerRef(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseDockerRef", reflect.TypeOf((*MockContainerdClient)(nil).ParseDockerRef), arg0) +} + +// ParsePlatform mocks base method. +func (m *MockContainerdClient) ParsePlatform(arg0 string) (v1.Platform, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ParsePlatform", arg0) + ret0, _ := ret[0].(v1.Platform) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ParsePlatform indicates an expected call of ParsePlatform. +func (mr *MockContainerdClientMockRecorder) ParsePlatform(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParsePlatform", reflect.TypeOf((*MockContainerdClient)(nil).ParsePlatform), arg0) +} + +// PublishEvent mocks base method. +func (m *MockContainerdClient) PublishEvent(arg0 context.Context, arg1 string, arg2 events.Event) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishEvent", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PublishEvent indicates an expected call of PublishEvent. +func (mr *MockContainerdClientMockRecorder) PublishEvent(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishEvent", reflect.TypeOf((*MockContainerdClient)(nil).PublishEvent), arg0, arg1, arg2) +} + +// SearchContainer mocks base method. +func (m *MockContainerdClient) SearchContainer(arg0 context.Context, arg1 string) ([]containerd.Container, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchContainer", arg0, arg1) + ret0, _ := ret[0].([]containerd.Container) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchContainer indicates an expected call of SearchContainer. +func (mr *MockContainerdClientMockRecorder) SearchContainer(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchContainer", reflect.TypeOf((*MockContainerdClient)(nil).SearchContainer), arg0, arg1) +} + +// SearchImage mocks base method. +func (m *MockContainerdClient) SearchImage(arg0 context.Context, arg1 string) ([]images.Image, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchImage", arg0, arg1) + ret0, _ := ret[0].([]images.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchImage indicates an expected call of SearchImage. +func (mr *MockContainerdClientMockRecorder) SearchImage(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchImage", reflect.TypeOf((*MockContainerdClient)(nil).SearchImage), arg0, arg1) +} + +// SubscribeToEvents mocks base method. +func (m *MockContainerdClient) SubscribeToEvents(arg0 context.Context, arg1 ...string) (<-chan *events.Envelope, <-chan error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SubscribeToEvents", varargs...) + ret0, _ := ret[0].(<-chan *events.Envelope) + ret1, _ := ret[1].(<-chan error) + return ret0, ret1 +} + +// SubscribeToEvents indicates an expected call of SubscribeToEvents. +func (mr *MockContainerdClientMockRecorder) SubscribeToEvents(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubscribeToEvents", reflect.TypeOf((*MockContainerdClient)(nil).SubscribeToEvents), varargs...) +} + +// Unmount mocks base method. +func (m *MockContainerdClient) Unmount(arg0 string, arg1 int) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Unmount", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Unmount indicates an expected call of Unmount. +func (mr *MockContainerdClientMockRecorder) Unmount(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unmount", reflect.TypeOf((*MockContainerdClient)(nil).Unmount), arg0, arg1) +} diff --git a/pkg/mocks/mocks_backend/nerdctlbuildersvc.go b/pkg/mocks/mocks_backend/nerdctlbuildersvc.go new file mode 100644 index 00000000..8392a800 --- /dev/null +++ b/pkg/mocks/mocks_backend/nerdctlbuildersvc.go @@ -0,0 +1,66 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/backend (interfaces: NerdctlBuilderSvc) + +// Package mocks_backend is a generated GoMock package. +package mocks_backend + +import ( + context "context" + reflect "reflect" + + types "github.com/containerd/nerdctl/pkg/api/types" + gomock "github.com/golang/mock/gomock" + backend "github.com/runfinch/finch-daemon/pkg/backend" +) + +// MockNerdctlBuilderSvc is a mock of NerdctlBuilderSvc interface. +type MockNerdctlBuilderSvc struct { + ctrl *gomock.Controller + recorder *MockNerdctlBuilderSvcMockRecorder +} + +// MockNerdctlBuilderSvcMockRecorder is the mock recorder for MockNerdctlBuilderSvc. +type MockNerdctlBuilderSvcMockRecorder struct { + mock *MockNerdctlBuilderSvc +} + +// NewMockNerdctlBuilderSvc creates a new mock instance. +func NewMockNerdctlBuilderSvc(ctrl *gomock.Controller) *MockNerdctlBuilderSvc { + mock := &MockNerdctlBuilderSvc{ctrl: ctrl} + mock.recorder = &MockNerdctlBuilderSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNerdctlBuilderSvc) EXPECT() *MockNerdctlBuilderSvcMockRecorder { + return m.recorder +} + +// Build mocks base method. +func (m *MockNerdctlBuilderSvc) Build(arg0 context.Context, arg1 backend.ContainerdClient, arg2 types.BuilderBuildOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Build", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Build indicates an expected call of Build. +func (mr *MockNerdctlBuilderSvcMockRecorder) Build(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockNerdctlBuilderSvc)(nil).Build), arg0, arg1, arg2) +} + +// GetBuildkitHost mocks base method. +func (m *MockNerdctlBuilderSvc) GetBuildkitHost() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBuildkitHost") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBuildkitHost indicates an expected call of GetBuildkitHost. +func (mr *MockNerdctlBuilderSvcMockRecorder) GetBuildkitHost() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBuildkitHost", reflect.TypeOf((*MockNerdctlBuilderSvc)(nil).GetBuildkitHost)) +} diff --git a/pkg/mocks/mocks_backend/nerdctlcontainersvc.go b/pkg/mocks/mocks_backend/nerdctlcontainersvc.go new file mode 100644 index 00000000..d8cdb394 --- /dev/null +++ b/pkg/mocks/mocks_backend/nerdctlcontainersvc.go @@ -0,0 +1,236 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/backend (interfaces: NerdctlContainerSvc) + +// Package mocks_backend is a generated GoMock package. +package mocks_backend + +import ( + context "context" + io "io" + os "os" + reflect "reflect" + time "time" + + containerd "github.com/containerd/containerd" + types "github.com/containerd/nerdctl/pkg/api/types" + container "github.com/containerd/nerdctl/pkg/cmd/container" + containerutil "github.com/containerd/nerdctl/pkg/containerutil" + dockercompat "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + native "github.com/containerd/nerdctl/pkg/inspecttypes/native" + logging "github.com/containerd/nerdctl/pkg/logging" + gomock "github.com/golang/mock/gomock" +) + +// MockNerdctlContainerSvc is a mock of NerdctlContainerSvc interface. +type MockNerdctlContainerSvc struct { + ctrl *gomock.Controller + recorder *MockNerdctlContainerSvcMockRecorder +} + +// MockNerdctlContainerSvcMockRecorder is the mock recorder for MockNerdctlContainerSvc. +type MockNerdctlContainerSvcMockRecorder struct { + mock *MockNerdctlContainerSvc +} + +// NewMockNerdctlContainerSvc creates a new mock instance. +func NewMockNerdctlContainerSvc(ctrl *gomock.Controller) *MockNerdctlContainerSvc { + mock := &MockNerdctlContainerSvc{ctrl: ctrl} + mock.recorder = &MockNerdctlContainerSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNerdctlContainerSvc) EXPECT() *MockNerdctlContainerSvcMockRecorder { + return m.recorder +} + +// CreateContainer mocks base method. +func (m *MockNerdctlContainerSvc) CreateContainer(arg0 context.Context, arg1 []string, arg2 containerutil.NetworkOptionsManager, arg3 types.ContainerCreateOptions) (containerd.Container, func(), error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateContainer", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(containerd.Container) + ret1, _ := ret[1].(func()) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateContainer indicates an expected call of CreateContainer. +func (mr *MockNerdctlContainerSvcMockRecorder) CreateContainer(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateContainer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).CreateContainer), arg0, arg1, arg2, arg3) +} + +// GetDataStore mocks base method. +func (m *MockNerdctlContainerSvc) GetDataStore() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDataStore") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDataStore indicates an expected call of GetDataStore. +func (mr *MockNerdctlContainerSvcMockRecorder) GetDataStore() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDataStore", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).GetDataStore)) +} + +// GetNerdctlExe mocks base method. +func (m *MockNerdctlContainerSvc) GetNerdctlExe() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNerdctlExe") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNerdctlExe indicates an expected call of GetNerdctlExe. +func (mr *MockNerdctlContainerSvcMockRecorder) GetNerdctlExe() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNerdctlExe", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).GetNerdctlExe)) +} + +// InspectContainer mocks base method. +func (m *MockNerdctlContainerSvc) InspectContainer(arg0 context.Context, arg1 containerd.Container) (*dockercompat.Container, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InspectContainer", arg0, arg1) + ret0, _ := ret[0].(*dockercompat.Container) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InspectContainer indicates an expected call of InspectContainer. +func (mr *MockNerdctlContainerSvcMockRecorder) InspectContainer(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectContainer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).InspectContainer), arg0, arg1) +} + +// InspectNetNS mocks base method. +func (m *MockNerdctlContainerSvc) InspectNetNS(arg0 context.Context, arg1 int) (*native.NetNS, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InspectNetNS", arg0, arg1) + ret0, _ := ret[0].(*native.NetNS) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InspectNetNS indicates an expected call of InspectNetNS. +func (mr *MockNerdctlContainerSvcMockRecorder) InspectNetNS(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectNetNS", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).InspectNetNS), arg0, arg1) +} + +// ListContainers mocks base method. +func (m *MockNerdctlContainerSvc) ListContainers(arg0 context.Context, arg1 types.ContainerListOptions) ([]container.ListItem, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListContainers", arg0, arg1) + ret0, _ := ret[0].([]container.ListItem) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListContainers indicates an expected call of ListContainers. +func (mr *MockNerdctlContainerSvcMockRecorder) ListContainers(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListContainers", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).ListContainers), arg0, arg1) +} + +// LoggingInitContainerLogViewer mocks base method. +func (m *MockNerdctlContainerSvc) LoggingInitContainerLogViewer(arg0 map[string]string, arg1 logging.LogViewOptions, arg2 chan os.Signal, arg3 bool) (*logging.ContainerLogViewer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoggingInitContainerLogViewer", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*logging.ContainerLogViewer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoggingInitContainerLogViewer indicates an expected call of LoggingInitContainerLogViewer. +func (mr *MockNerdctlContainerSvcMockRecorder) LoggingInitContainerLogViewer(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoggingInitContainerLogViewer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).LoggingInitContainerLogViewer), arg0, arg1, arg2, arg3) +} + +// LoggingPrintLogsTo mocks base method. +func (m *MockNerdctlContainerSvc) LoggingPrintLogsTo(arg0, arg1 io.Writer, arg2 *logging.ContainerLogViewer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoggingPrintLogsTo", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// LoggingPrintLogsTo indicates an expected call of LoggingPrintLogsTo. +func (mr *MockNerdctlContainerSvcMockRecorder) LoggingPrintLogsTo(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoggingPrintLogsTo", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).LoggingPrintLogsTo), arg0, arg1, arg2) +} + +// NewNetworkingOptionsManager mocks base method. +func (m *MockNerdctlContainerSvc) NewNetworkingOptionsManager(arg0 types.NetworkOptions) (containerutil.NetworkOptionsManager, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewNetworkingOptionsManager", arg0) + ret0, _ := ret[0].(containerutil.NetworkOptionsManager) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewNetworkingOptionsManager indicates an expected call of NewNetworkingOptionsManager. +func (mr *MockNerdctlContainerSvcMockRecorder) NewNetworkingOptionsManager(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewNetworkingOptionsManager", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).NewNetworkingOptionsManager), arg0) +} + +// RemoveContainer mocks base method. +func (m *MockNerdctlContainerSvc) RemoveContainer(arg0 context.Context, arg1 containerd.Container, arg2, arg3 bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveContainer", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveContainer indicates an expected call of RemoveContainer. +func (mr *MockNerdctlContainerSvcMockRecorder) RemoveContainer(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveContainer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).RemoveContainer), arg0, arg1, arg2, arg3) +} + +// RenameContainer mocks base method. +func (m *MockNerdctlContainerSvc) RenameContainer(arg0 context.Context, arg1 containerd.Container, arg2 string, arg3 types.ContainerRenameOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenameContainer", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenameContainer indicates an expected call of RenameContainer. +func (mr *MockNerdctlContainerSvcMockRecorder) RenameContainer(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenameContainer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).RenameContainer), arg0, arg1, arg2, arg3) +} + +// StartContainer mocks base method. +func (m *MockNerdctlContainerSvc) StartContainer(arg0 context.Context, arg1 containerd.Container) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartContainer", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// StartContainer indicates an expected call of StartContainer. +func (mr *MockNerdctlContainerSvcMockRecorder) StartContainer(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartContainer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).StartContainer), arg0, arg1) +} + +// StopContainer mocks base method. +func (m *MockNerdctlContainerSvc) StopContainer(arg0 context.Context, arg1 containerd.Container, arg2 *time.Duration) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StopContainer", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// StopContainer indicates an expected call of StopContainer. +func (mr *MockNerdctlContainerSvcMockRecorder) StopContainer(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopContainer", reflect.TypeOf((*MockNerdctlContainerSvc)(nil).StopContainer), arg0, arg1, arg2) +} diff --git a/pkg/mocks/mocks_backend/nerdctlimagesvc.go b/pkg/mocks/mocks_backend/nerdctlimagesvc.go new file mode 100644 index 00000000..fa8da536 --- /dev/null +++ b/pkg/mocks/mocks_backend/nerdctlimagesvc.go @@ -0,0 +1,121 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/backend (interfaces: NerdctlImageSvc) + +// Package mocks_backend is a generated GoMock package. +package mocks_backend + +import ( + context "context" + io "io" + reflect "reflect" + + images "github.com/containerd/containerd/images" + platforms "github.com/containerd/containerd/platforms" + remotes "github.com/containerd/containerd/remotes" + docker "github.com/containerd/containerd/remotes/docker" + imgutil "github.com/containerd/nerdctl/pkg/imgutil" + dockerconfigresolver "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + dockercompat "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + gomock "github.com/golang/mock/gomock" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +// MockNerdctlImageSvc is a mock of NerdctlImageSvc interface. +type MockNerdctlImageSvc struct { + ctrl *gomock.Controller + recorder *MockNerdctlImageSvcMockRecorder +} + +// MockNerdctlImageSvcMockRecorder is the mock recorder for MockNerdctlImageSvc. +type MockNerdctlImageSvcMockRecorder struct { + mock *MockNerdctlImageSvc +} + +// NewMockNerdctlImageSvc creates a new mock instance. +func NewMockNerdctlImageSvc(ctrl *gomock.Controller) *MockNerdctlImageSvc { + mock := &MockNerdctlImageSvc{ctrl: ctrl} + mock.recorder = &MockNerdctlImageSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNerdctlImageSvc) EXPECT() *MockNerdctlImageSvcMockRecorder { + return m.recorder +} + +// GetDockerResolver mocks base method. +func (m *MockNerdctlImageSvc) GetDockerResolver(arg0 context.Context, arg1 string, arg2 dockerconfigresolver.AuthCreds) (remotes.Resolver, docker.StatusTracker, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDockerResolver", arg0, arg1, arg2) + ret0, _ := ret[0].(remotes.Resolver) + ret1, _ := ret[1].(docker.StatusTracker) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetDockerResolver indicates an expected call of GetDockerResolver. +func (mr *MockNerdctlImageSvcMockRecorder) GetDockerResolver(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDockerResolver", reflect.TypeOf((*MockNerdctlImageSvc)(nil).GetDockerResolver), arg0, arg1, arg2) +} + +// InspectImage mocks base method. +func (m *MockNerdctlImageSvc) InspectImage(arg0 context.Context, arg1 images.Image) (*dockercompat.Image, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InspectImage", arg0, arg1) + ret0, _ := ret[0].(*dockercompat.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InspectImage indicates an expected call of InspectImage. +func (mr *MockNerdctlImageSvcMockRecorder) InspectImage(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectImage", reflect.TypeOf((*MockNerdctlImageSvc)(nil).InspectImage), arg0, arg1) +} + +// PullImage mocks base method. +func (m *MockNerdctlImageSvc) PullImage(arg0 context.Context, arg1, arg2 io.Writer, arg3 remotes.Resolver, arg4 string, arg5 []v1.Platform) (*imgutil.EnsuredImage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PullImage", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*imgutil.EnsuredImage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PullImage indicates an expected call of PullImage. +func (mr *MockNerdctlImageSvcMockRecorder) PullImage(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PullImage", reflect.TypeOf((*MockNerdctlImageSvc)(nil).PullImage), arg0, arg1, arg2, arg3, arg4, arg5) +} + +// PushImage mocks base method. +func (m *MockNerdctlImageSvc) PushImage(arg0 context.Context, arg1 remotes.Resolver, arg2 docker.StatusTracker, arg3 io.Writer, arg4, arg5 string, arg6 platforms.MatchComparer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PushImage", arg0, arg1, arg2, arg3, arg4, arg5, arg6) + ret0, _ := ret[0].(error) + return ret0 +} + +// PushImage indicates an expected call of PushImage. +func (mr *MockNerdctlImageSvcMockRecorder) PushImage(arg0, arg1, arg2, arg3, arg4, arg5, arg6 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushImage", reflect.TypeOf((*MockNerdctlImageSvc)(nil).PushImage), arg0, arg1, arg2, arg3, arg4, arg5, arg6) +} + +// SearchImage mocks base method. +func (m *MockNerdctlImageSvc) SearchImage(arg0 context.Context, arg1 string) (int, int, []*images.Image, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchImage", arg0, arg1) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].([]*images.Image) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// SearchImage indicates an expected call of SearchImage. +func (mr *MockNerdctlImageSvcMockRecorder) SearchImage(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchImage", reflect.TypeOf((*MockNerdctlImageSvc)(nil).SearchImage), arg0, arg1) +} diff --git a/pkg/mocks/mocks_backend/nerdctlnetworksvc.go b/pkg/mocks/mocks_backend/nerdctlnetworksvc.go new file mode 100644 index 00000000..c0eae54c --- /dev/null +++ b/pkg/mocks/mocks_backend/nerdctlnetworksvc.go @@ -0,0 +1,142 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/backend (interfaces: NerdctlNetworkSvc) + +// Package mocks_backend is a generated GoMock package. +package mocks_backend + +import ( + context "context" + reflect "reflect" + + dockercompat "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + netutil "github.com/containerd/nerdctl/pkg/netutil" + libcni "github.com/containernetworking/cni/libcni" + types "github.com/containernetworking/cni/pkg/types" + gomock "github.com/golang/mock/gomock" +) + +// MockNerdctlNetworkSvc is a mock of NerdctlNetworkSvc interface. +type MockNerdctlNetworkSvc struct { + ctrl *gomock.Controller + recorder *MockNerdctlNetworkSvcMockRecorder +} + +// MockNerdctlNetworkSvcMockRecorder is the mock recorder for MockNerdctlNetworkSvc. +type MockNerdctlNetworkSvcMockRecorder struct { + mock *MockNerdctlNetworkSvc +} + +// NewMockNerdctlNetworkSvc creates a new mock instance. +func NewMockNerdctlNetworkSvc(ctrl *gomock.Controller) *MockNerdctlNetworkSvc { + mock := &MockNerdctlNetworkSvc{ctrl: ctrl} + mock.recorder = &MockNerdctlNetworkSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNerdctlNetworkSvc) EXPECT() *MockNerdctlNetworkSvcMockRecorder { + return m.recorder +} + +// AddNetworkList mocks base method. +func (m *MockNerdctlNetworkSvc) AddNetworkList(arg0 context.Context, arg1 *libcni.NetworkConfigList, arg2 *libcni.RuntimeConf) (types.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddNetworkList", arg0, arg1, arg2) + ret0, _ := ret[0].(types.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddNetworkList indicates an expected call of AddNetworkList. +func (mr *MockNerdctlNetworkSvcMockRecorder) AddNetworkList(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddNetworkList", reflect.TypeOf((*MockNerdctlNetworkSvc)(nil).AddNetworkList), arg0, arg1, arg2) +} + +// CreateNetwork mocks base method. +func (m *MockNerdctlNetworkSvc) CreateNetwork(arg0 netutil.CreateOptions) (*netutil.NetworkConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateNetwork", arg0) + ret0, _ := ret[0].(*netutil.NetworkConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateNetwork indicates an expected call of CreateNetwork. +func (mr *MockNerdctlNetworkSvcMockRecorder) CreateNetwork(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNetwork", reflect.TypeOf((*MockNerdctlNetworkSvc)(nil).CreateNetwork), arg0) +} + +// FilterNetworks mocks base method. +func (m *MockNerdctlNetworkSvc) FilterNetworks(arg0 func(*netutil.NetworkConfig) bool) ([]*netutil.NetworkConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FilterNetworks", arg0) + ret0, _ := ret[0].([]*netutil.NetworkConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FilterNetworks indicates an expected call of FilterNetworks. +func (mr *MockNerdctlNetworkSvcMockRecorder) FilterNetworks(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilterNetworks", reflect.TypeOf((*MockNerdctlNetworkSvc)(nil).FilterNetworks), arg0) +} + +// InspectNetwork mocks base method. +func (m *MockNerdctlNetworkSvc) InspectNetwork(arg0 context.Context, arg1 *netutil.NetworkConfig) (*dockercompat.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InspectNetwork", arg0, arg1) + ret0, _ := ret[0].(*dockercompat.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InspectNetwork indicates an expected call of InspectNetwork. +func (mr *MockNerdctlNetworkSvcMockRecorder) InspectNetwork(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectNetwork", reflect.TypeOf((*MockNerdctlNetworkSvc)(nil).InspectNetwork), arg0, arg1) +} + +// NetconfPath mocks base method. +func (m *MockNerdctlNetworkSvc) NetconfPath() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NetconfPath") + ret0, _ := ret[0].(string) + return ret0 +} + +// NetconfPath indicates an expected call of NetconfPath. +func (mr *MockNerdctlNetworkSvcMockRecorder) NetconfPath() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NetconfPath", reflect.TypeOf((*MockNerdctlNetworkSvc)(nil).NetconfPath)) +} + +// RemoveNetwork mocks base method. +func (m *MockNerdctlNetworkSvc) RemoveNetwork(arg0 *netutil.NetworkConfig) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveNetwork", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveNetwork indicates an expected call of RemoveNetwork. +func (mr *MockNerdctlNetworkSvcMockRecorder) RemoveNetwork(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveNetwork", reflect.TypeOf((*MockNerdctlNetworkSvc)(nil).RemoveNetwork), arg0) +} + +// UsedNetworkInfo mocks base method. +func (m *MockNerdctlNetworkSvc) UsedNetworkInfo(arg0 context.Context) (map[string][]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UsedNetworkInfo", arg0) + ret0, _ := ret[0].(map[string][]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UsedNetworkInfo indicates an expected call of UsedNetworkInfo. +func (mr *MockNerdctlNetworkSvcMockRecorder) UsedNetworkInfo(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsedNetworkInfo", reflect.TypeOf((*MockNerdctlNetworkSvc)(nil).UsedNetworkInfo), arg0) +} diff --git a/pkg/mocks/mocks_backend/nerdctlsystemsvc.go b/pkg/mocks/mocks_backend/nerdctlsystemsvc.go new file mode 100644 index 00000000..e28de740 --- /dev/null +++ b/pkg/mocks/mocks_backend/nerdctlsystemsvc.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/backend (interfaces: NerdctlSystemSvc) + +// Package mocks_backend is a generated GoMock package. +package mocks_backend + +import ( + context "context" + reflect "reflect" + + dockercompat "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + gomock "github.com/golang/mock/gomock" +) + +// MockNerdctlSystemSvc is a mock of NerdctlSystemSvc interface. +type MockNerdctlSystemSvc struct { + ctrl *gomock.Controller + recorder *MockNerdctlSystemSvcMockRecorder +} + +// MockNerdctlSystemSvcMockRecorder is the mock recorder for MockNerdctlSystemSvc. +type MockNerdctlSystemSvcMockRecorder struct { + mock *MockNerdctlSystemSvc +} + +// NewMockNerdctlSystemSvc creates a new mock instance. +func NewMockNerdctlSystemSvc(ctrl *gomock.Controller) *MockNerdctlSystemSvc { + mock := &MockNerdctlSystemSvc{ctrl: ctrl} + mock.recorder = &MockNerdctlSystemSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNerdctlSystemSvc) EXPECT() *MockNerdctlSystemSvcMockRecorder { + return m.recorder +} + +// GetServerVersion mocks base method. +func (m *MockNerdctlSystemSvc) GetServerVersion(arg0 context.Context) (*dockercompat.ServerVersion, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServerVersion", arg0) + ret0, _ := ret[0].(*dockercompat.ServerVersion) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServerVersion indicates an expected call of GetServerVersion. +func (mr *MockNerdctlSystemSvcMockRecorder) GetServerVersion(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServerVersion", reflect.TypeOf((*MockNerdctlSystemSvc)(nil).GetServerVersion), arg0) +} diff --git a/pkg/mocks/mocks_backend/nerdctlvolumesvc.go b/pkg/mocks/mocks_backend/nerdctlvolumesvc.go new file mode 100644 index 00000000..273714b1 --- /dev/null +++ b/pkg/mocks/mocks_backend/nerdctlvolumesvc.go @@ -0,0 +1,96 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/backend (interfaces: NerdctlVolumeSvc) + +// Package mocks_backend is a generated GoMock package. +package mocks_backend + +import ( + context "context" + io "io" + reflect "reflect" + + native "github.com/containerd/nerdctl/pkg/inspecttypes/native" + gomock "github.com/golang/mock/gomock" +) + +// MockNerdctlVolumeSvc is a mock of NerdctlVolumeSvc interface. +type MockNerdctlVolumeSvc struct { + ctrl *gomock.Controller + recorder *MockNerdctlVolumeSvcMockRecorder +} + +// MockNerdctlVolumeSvcMockRecorder is the mock recorder for MockNerdctlVolumeSvc. +type MockNerdctlVolumeSvcMockRecorder struct { + mock *MockNerdctlVolumeSvc +} + +// NewMockNerdctlVolumeSvc creates a new mock instance. +func NewMockNerdctlVolumeSvc(ctrl *gomock.Controller) *MockNerdctlVolumeSvc { + mock := &MockNerdctlVolumeSvc{ctrl: ctrl} + mock.recorder = &MockNerdctlVolumeSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNerdctlVolumeSvc) EXPECT() *MockNerdctlVolumeSvcMockRecorder { + return m.recorder +} + +// CreateVolume mocks base method. +func (m *MockNerdctlVolumeSvc) CreateVolume(arg0 string, arg1 []string) (*native.Volume, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateVolume", arg0, arg1) + ret0, _ := ret[0].(*native.Volume) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateVolume indicates an expected call of CreateVolume. +func (mr *MockNerdctlVolumeSvcMockRecorder) CreateVolume(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateVolume", reflect.TypeOf((*MockNerdctlVolumeSvc)(nil).CreateVolume), arg0, arg1) +} + +// GetVolume mocks base method. +func (m *MockNerdctlVolumeSvc) GetVolume(arg0 string) (*native.Volume, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVolume", arg0) + ret0, _ := ret[0].(*native.Volume) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetVolume indicates an expected call of GetVolume. +func (mr *MockNerdctlVolumeSvcMockRecorder) GetVolume(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVolume", reflect.TypeOf((*MockNerdctlVolumeSvc)(nil).GetVolume), arg0) +} + +// ListVolumes mocks base method. +func (m *MockNerdctlVolumeSvc) ListVolumes(arg0 bool, arg1 []string) (map[string]native.Volume, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListVolumes", arg0, arg1) + ret0, _ := ret[0].(map[string]native.Volume) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListVolumes indicates an expected call of ListVolumes. +func (mr *MockNerdctlVolumeSvcMockRecorder) ListVolumes(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListVolumes", reflect.TypeOf((*MockNerdctlVolumeSvc)(nil).ListVolumes), arg0, arg1) +} + +// RemoveVolume mocks base method. +func (m *MockNerdctlVolumeSvc) RemoveVolume(arg0 context.Context, arg1 string, arg2 bool, arg3 io.Writer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveVolume", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveVolume indicates an expected call of RemoveVolume. +func (mr *MockNerdctlVolumeSvcMockRecorder) RemoveVolume(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveVolume", reflect.TypeOf((*MockNerdctlVolumeSvc)(nil).RemoveVolume), arg0, arg1, arg2, arg3) +} diff --git a/pkg/mocks/mocks_builder/buildersvc.go b/pkg/mocks/mocks_builder/buildersvc.go new file mode 100644 index 00000000..a260807e --- /dev/null +++ b/pkg/mocks/mocks_builder/buildersvc.go @@ -0,0 +1,53 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/api/handlers/builder (interfaces: Service) + +// Package mocks_builder is a generated GoMock package. +package mocks_builder + +import ( + context "context" + io "io" + reflect "reflect" + + types "github.com/containerd/nerdctl/pkg/api/types" + gomock "github.com/golang/mock/gomock" + types0 "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Build mocks base method. +func (m *MockService) Build(arg0 context.Context, arg1 *types.BuilderBuildOptions, arg2 io.ReadCloser) ([]types0.BuildResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Build", arg0, arg1, arg2) + ret0, _ := ret[0].([]types0.BuildResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Build indicates an expected call of Build. +func (mr *MockServiceMockRecorder) Build(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockService)(nil).Build), arg0, arg1, arg2) +} diff --git a/pkg/mocks/mocks_cio/io.go b/pkg/mocks/mocks_cio/io.go new file mode 100644 index 00000000..1c5b7f9e --- /dev/null +++ b/pkg/mocks/mocks_cio/io.go @@ -0,0 +1,87 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/containerd/containerd/cio (interfaces: IO) + +// Package mocks_cio is a generated GoMock package. +package mocks_cio + +import ( + reflect "reflect" + + cio "github.com/containerd/containerd/cio" + gomock "github.com/golang/mock/gomock" +) + +// MockIO is a mock of IO interface. +type MockIO struct { + ctrl *gomock.Controller + recorder *MockIOMockRecorder +} + +// MockIOMockRecorder is the mock recorder for MockIO. +type MockIOMockRecorder struct { + mock *MockIO +} + +// NewMockIO creates a new mock instance. +func NewMockIO(ctrl *gomock.Controller) *MockIO { + mock := &MockIO{ctrl: ctrl} + mock.recorder = &MockIOMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIO) EXPECT() *MockIOMockRecorder { + return m.recorder +} + +// Cancel mocks base method. +func (m *MockIO) Cancel() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Cancel") +} + +// Cancel indicates an expected call of Cancel. +func (mr *MockIOMockRecorder) Cancel() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cancel", reflect.TypeOf((*MockIO)(nil).Cancel)) +} + +// Close mocks base method. +func (m *MockIO) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockIOMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockIO)(nil).Close)) +} + +// Config mocks base method. +func (m *MockIO) Config() cio.Config { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Config") + ret0, _ := ret[0].(cio.Config) + return ret0 +} + +// Config indicates an expected call of Config. +func (mr *MockIOMockRecorder) Config() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Config", reflect.TypeOf((*MockIO)(nil).Config)) +} + +// Wait mocks base method. +func (m *MockIO) Wait() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Wait") +} + +// Wait indicates an expected call of Wait. +func (mr *MockIOMockRecorder) Wait() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockIO)(nil).Wait)) +} diff --git a/pkg/mocks/mocks_container/container.go b/pkg/mocks/mocks_container/container.go new file mode 100644 index 00000000..4ea14543 --- /dev/null +++ b/pkg/mocks/mocks_container/container.go @@ -0,0 +1,242 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/containerd/containerd (interfaces: Container) + +// Package mocks_container is a generated GoMock package. +package mocks_container + +import ( + context "context" + reflect "reflect" + + containerd "github.com/containerd/containerd" + cio "github.com/containerd/containerd/cio" + containers "github.com/containerd/containerd/containers" + typeurl "github.com/containerd/typeurl/v2" + gomock "github.com/golang/mock/gomock" + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +// MockContainer is a mock of Container interface. +type MockContainer struct { + ctrl *gomock.Controller + recorder *MockContainerMockRecorder +} + +// MockContainerMockRecorder is the mock recorder for MockContainer. +type MockContainerMockRecorder struct { + mock *MockContainer +} + +// NewMockContainer creates a new mock instance. +func NewMockContainer(ctrl *gomock.Controller) *MockContainer { + mock := &MockContainer{ctrl: ctrl} + mock.recorder = &MockContainerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockContainer) EXPECT() *MockContainerMockRecorder { + return m.recorder +} + +// Checkpoint mocks base method. +func (m *MockContainer) Checkpoint(arg0 context.Context, arg1 string, arg2 ...containerd.CheckpointOpts) (containerd.Image, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Checkpoint", varargs...) + ret0, _ := ret[0].(containerd.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Checkpoint indicates an expected call of Checkpoint. +func (mr *MockContainerMockRecorder) Checkpoint(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Checkpoint", reflect.TypeOf((*MockContainer)(nil).Checkpoint), varargs...) +} + +// Delete mocks base method. +func (m *MockContainer) Delete(arg0 context.Context, arg1 ...containerd.DeleteOpts) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockContainerMockRecorder) Delete(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockContainer)(nil).Delete), varargs...) +} + +// Extensions mocks base method. +func (m *MockContainer) Extensions(arg0 context.Context) (map[string]typeurl.Any, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Extensions", arg0) + ret0, _ := ret[0].(map[string]typeurl.Any) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Extensions indicates an expected call of Extensions. +func (mr *MockContainerMockRecorder) Extensions(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Extensions", reflect.TypeOf((*MockContainer)(nil).Extensions), arg0) +} + +// ID mocks base method. +func (m *MockContainer) ID() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ID") + ret0, _ := ret[0].(string) + return ret0 +} + +// ID indicates an expected call of ID. +func (mr *MockContainerMockRecorder) ID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockContainer)(nil).ID)) +} + +// Image mocks base method. +func (m *MockContainer) Image(arg0 context.Context) (containerd.Image, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Image", arg0) + ret0, _ := ret[0].(containerd.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Image indicates an expected call of Image. +func (mr *MockContainerMockRecorder) Image(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Image", reflect.TypeOf((*MockContainer)(nil).Image), arg0) +} + +// Info mocks base method. +func (m *MockContainer) Info(arg0 context.Context, arg1 ...containerd.InfoOpts) (containers.Container, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Info", varargs...) + ret0, _ := ret[0].(containers.Container) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Info indicates an expected call of Info. +func (mr *MockContainerMockRecorder) Info(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockContainer)(nil).Info), varargs...) +} + +// Labels mocks base method. +func (m *MockContainer) Labels(arg0 context.Context) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Labels", arg0) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Labels indicates an expected call of Labels. +func (mr *MockContainerMockRecorder) Labels(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Labels", reflect.TypeOf((*MockContainer)(nil).Labels), arg0) +} + +// NewTask mocks base method. +func (m *MockContainer) NewTask(arg0 context.Context, arg1 cio.Creator, arg2 ...containerd.NewTaskOpts) (containerd.Task, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "NewTask", varargs...) + ret0, _ := ret[0].(containerd.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewTask indicates an expected call of NewTask. +func (mr *MockContainerMockRecorder) NewTask(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewTask", reflect.TypeOf((*MockContainer)(nil).NewTask), varargs...) +} + +// SetLabels mocks base method. +func (m *MockContainer) SetLabels(arg0 context.Context, arg1 map[string]string) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetLabels", arg0, arg1) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetLabels indicates an expected call of SetLabels. +func (mr *MockContainerMockRecorder) SetLabels(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLabels", reflect.TypeOf((*MockContainer)(nil).SetLabels), arg0, arg1) +} + +// Spec mocks base method. +func (m *MockContainer) Spec(arg0 context.Context) (*specs.Spec, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Spec", arg0) + ret0, _ := ret[0].(*specs.Spec) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Spec indicates an expected call of Spec. +func (mr *MockContainerMockRecorder) Spec(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Spec", reflect.TypeOf((*MockContainer)(nil).Spec), arg0) +} + +// Task mocks base method. +func (m *MockContainer) Task(arg0 context.Context, arg1 cio.Attach) (containerd.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Task", arg0, arg1) + ret0, _ := ret[0].(containerd.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Task indicates an expected call of Task. +func (mr *MockContainerMockRecorder) Task(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Task", reflect.TypeOf((*MockContainer)(nil).Task), arg0, arg1) +} + +// Update mocks base method. +func (m *MockContainer) Update(arg0 context.Context, arg1 ...containerd.UpdateContainerOpts) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockContainerMockRecorder) Update(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockContainer)(nil).Update), varargs...) +} diff --git a/pkg/mocks/mocks_container/containersvc.go b/pkg/mocks/mocks_container/containersvc.go new file mode 100644 index 00000000..0b37f7f7 --- /dev/null +++ b/pkg/mocks/mocks_container/containersvc.go @@ -0,0 +1,257 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/api/handlers/container (interfaces: Service) + +// Package mocks_container is a generated GoMock package. +package mocks_container + +import ( + context "context" + io "io" + reflect "reflect" + time "time" + + types "github.com/containerd/nerdctl/pkg/api/types" + gomock "github.com/golang/mock/gomock" + types0 "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Attach mocks base method. +func (m *MockService) Attach(arg0 context.Context, arg1 string, arg2 *types0.AttachOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Attach", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Attach indicates an expected call of Attach. +func (mr *MockServiceMockRecorder) Attach(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Attach", reflect.TypeOf((*MockService)(nil).Attach), arg0, arg1, arg2) +} + +// Create mocks base method. +func (m *MockService) Create(arg0 context.Context, arg1 string, arg2 []string, arg3 types.ContainerCreateOptions, arg4 types.NetworkOptions) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockServiceMockRecorder) Create(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockService)(nil).Create), arg0, arg1, arg2, arg3, arg4) +} + +// ExecCreate mocks base method. +func (m *MockService) ExecCreate(arg0 context.Context, arg1 string, arg2 types0.ExecConfig) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExecCreate", arg0, arg1, arg2) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExecCreate indicates an expected call of ExecCreate. +func (mr *MockServiceMockRecorder) ExecCreate(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecCreate", reflect.TypeOf((*MockService)(nil).ExecCreate), arg0, arg1, arg2) +} + +// ExtractArchiveInContainer mocks base method. +func (m *MockService) ExtractArchiveInContainer(arg0 context.Context, arg1 *types0.PutArchiveOptions, arg2 io.ReadCloser) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExtractArchiveInContainer", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// ExtractArchiveInContainer indicates an expected call of ExtractArchiveInContainer. +func (mr *MockServiceMockRecorder) ExtractArchiveInContainer(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractArchiveInContainer", reflect.TypeOf((*MockService)(nil).ExtractArchiveInContainer), arg0, arg1, arg2) +} + +// GetPathToFilesInContainer mocks base method. +func (m *MockService) GetPathToFilesInContainer(arg0 context.Context, arg1, arg2 string) (string, func(), error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPathToFilesInContainer", arg0, arg1, arg2) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(func()) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPathToFilesInContainer indicates an expected call of GetPathToFilesInContainer. +func (mr *MockServiceMockRecorder) GetPathToFilesInContainer(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPathToFilesInContainer", reflect.TypeOf((*MockService)(nil).GetPathToFilesInContainer), arg0, arg1, arg2) +} + +// Inspect mocks base method. +func (m *MockService) Inspect(arg0 context.Context, arg1 string) (*types0.Container, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", arg0, arg1) + ret0, _ := ret[0].(*types0.Container) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockServiceMockRecorder) Inspect(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockService)(nil).Inspect), arg0, arg1) +} + +// List mocks base method. +func (m *MockService) List(arg0 context.Context, arg1 types.ContainerListOptions) ([]types0.ContainerListItem, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0, arg1) + ret0, _ := ret[0].([]types0.ContainerListItem) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockServiceMockRecorder) List(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockService)(nil).List), arg0, arg1) +} + +// Logs mocks base method. +func (m *MockService) Logs(arg0 context.Context, arg1 string, arg2 *types0.LogsOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Logs", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Logs indicates an expected call of Logs. +func (mr *MockServiceMockRecorder) Logs(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logs", reflect.TypeOf((*MockService)(nil).Logs), arg0, arg1, arg2) +} + +// Remove mocks base method. +func (m *MockService) Remove(arg0 context.Context, arg1 string, arg2, arg3 bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// Remove indicates an expected call of Remove. +func (mr *MockServiceMockRecorder) Remove(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockService)(nil).Remove), arg0, arg1, arg2, arg3) +} + +// Rename mocks base method. +func (m *MockService) Rename(arg0 context.Context, arg1, arg2 string, arg3 types.ContainerRenameOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Rename", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// Rename indicates an expected call of Rename. +func (mr *MockServiceMockRecorder) Rename(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rename", reflect.TypeOf((*MockService)(nil).Rename), arg0, arg1, arg2, arg3) +} + +// Start mocks base method. +func (m *MockService) Start(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Start indicates an expected call of Start. +func (mr *MockServiceMockRecorder) Start(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockService)(nil).Start), arg0, arg1) +} + +// Stats mocks base method. +func (m *MockService) Stats(arg0 context.Context, arg1 string) (<-chan *types0.StatsJSON, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Stats", arg0, arg1) + ret0, _ := ret[0].(<-chan *types0.StatsJSON) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Stats indicates an expected call of Stats. +func (mr *MockServiceMockRecorder) Stats(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stats", reflect.TypeOf((*MockService)(nil).Stats), arg0, arg1) +} + +// Stop mocks base method. +func (m *MockService) Stop(arg0 context.Context, arg1 string, arg2 *time.Duration) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Stop", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Stop indicates an expected call of Stop. +func (mr *MockServiceMockRecorder) Stop(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockService)(nil).Stop), arg0, arg1, arg2) +} + +// Wait mocks base method. +func (m *MockService) Wait(arg0 context.Context, arg1, arg2 string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Wait", arg0, arg1, arg2) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Wait indicates an expected call of Wait. +func (mr *MockServiceMockRecorder) Wait(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockService)(nil).Wait), arg0, arg1, arg2) +} + +// WriteFilesAsTarArchive mocks base method. +func (m *MockService) WriteFilesAsTarArchive(arg0 string, arg1 io.Writer, arg2 bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WriteFilesAsTarArchive", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// WriteFilesAsTarArchive indicates an expected call of WriteFilesAsTarArchive. +func (mr *MockServiceMockRecorder) WriteFilesAsTarArchive(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteFilesAsTarArchive", reflect.TypeOf((*MockService)(nil).WriteFilesAsTarArchive), arg0, arg1, arg2) +} diff --git a/pkg/mocks/mocks_container/network_manager.go b/pkg/mocks/mocks_container/network_manager.go new file mode 100644 index 00000000..1d1abee5 --- /dev/null +++ b/pkg/mocks/mocks_container/network_manager.go @@ -0,0 +1,125 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/containerd/nerdctl/pkg/containerutil (interfaces: NetworkOptionsManager) + +// Package mocks_container is a generated GoMock package. +package mocks_container + +import ( + context "context" + reflect "reflect" + + containerd "github.com/containerd/containerd" + oci "github.com/containerd/containerd/oci" + types "github.com/containerd/nerdctl/pkg/api/types" + gomock "github.com/golang/mock/gomock" +) + +// MockNetworkOptionsManager is a mock of NetworkOptionsManager interface. +type MockNetworkOptionsManager struct { + ctrl *gomock.Controller + recorder *MockNetworkOptionsManagerMockRecorder +} + +// MockNetworkOptionsManagerMockRecorder is the mock recorder for MockNetworkOptionsManager. +type MockNetworkOptionsManagerMockRecorder struct { + mock *MockNetworkOptionsManager +} + +// NewMockNetworkOptionsManager creates a new mock instance. +func NewMockNetworkOptionsManager(ctrl *gomock.Controller) *MockNetworkOptionsManager { + mock := &MockNetworkOptionsManager{ctrl: ctrl} + mock.recorder = &MockNetworkOptionsManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNetworkOptionsManager) EXPECT() *MockNetworkOptionsManagerMockRecorder { + return m.recorder +} + +// CleanupNetworking mocks base method. +func (m *MockNetworkOptionsManager) CleanupNetworking(arg0 context.Context, arg1 containerd.Container) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CleanupNetworking", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// CleanupNetworking indicates an expected call of CleanupNetworking. +func (mr *MockNetworkOptionsManagerMockRecorder) CleanupNetworking(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupNetworking", reflect.TypeOf((*MockNetworkOptionsManager)(nil).CleanupNetworking), arg0, arg1) +} + +// ContainerNetworkingOpts mocks base method. +func (m *MockNetworkOptionsManager) ContainerNetworkingOpts(arg0 context.Context, arg1 string) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ContainerNetworkingOpts", arg0, arg1) + ret0, _ := ret[0].([]oci.SpecOpts) + ret1, _ := ret[1].([]containerd.NewContainerOpts) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ContainerNetworkingOpts indicates an expected call of ContainerNetworkingOpts. +func (mr *MockNetworkOptionsManagerMockRecorder) ContainerNetworkingOpts(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerNetworkingOpts", reflect.TypeOf((*MockNetworkOptionsManager)(nil).ContainerNetworkingOpts), arg0, arg1) +} + +// InternalNetworkingOptionLabels mocks base method. +func (m *MockNetworkOptionsManager) InternalNetworkingOptionLabels(arg0 context.Context) (types.NetworkOptions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InternalNetworkingOptionLabels", arg0) + ret0, _ := ret[0].(types.NetworkOptions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InternalNetworkingOptionLabels indicates an expected call of InternalNetworkingOptionLabels. +func (mr *MockNetworkOptionsManagerMockRecorder) InternalNetworkingOptionLabels(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InternalNetworkingOptionLabels", reflect.TypeOf((*MockNetworkOptionsManager)(nil).InternalNetworkingOptionLabels), arg0) +} + +// NetworkOptions mocks base method. +func (m *MockNetworkOptionsManager) NetworkOptions() types.NetworkOptions { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NetworkOptions") + ret0, _ := ret[0].(types.NetworkOptions) + return ret0 +} + +// NetworkOptions indicates an expected call of NetworkOptions. +func (mr *MockNetworkOptionsManagerMockRecorder) NetworkOptions() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NetworkOptions", reflect.TypeOf((*MockNetworkOptionsManager)(nil).NetworkOptions)) +} + +// SetupNetworking mocks base method. +func (m *MockNetworkOptionsManager) SetupNetworking(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetupNetworking", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetupNetworking indicates an expected call of SetupNetworking. +func (mr *MockNetworkOptionsManagerMockRecorder) SetupNetworking(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetupNetworking", reflect.TypeOf((*MockNetworkOptionsManager)(nil).SetupNetworking), arg0, arg1) +} + +// VerifyNetworkOptions mocks base method. +func (m *MockNetworkOptionsManager) VerifyNetworkOptions(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyNetworkOptions", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// VerifyNetworkOptions indicates an expected call of VerifyNetworkOptions. +func (mr *MockNetworkOptionsManagerMockRecorder) VerifyNetworkOptions(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyNetworkOptions", reflect.TypeOf((*MockNetworkOptionsManager)(nil).VerifyNetworkOptions), arg0) +} diff --git a/pkg/mocks/mocks_container/process.go b/pkg/mocks/mocks_container/process.go new file mode 100644 index 00000000..099bc104 --- /dev/null +++ b/pkg/mocks/mocks_container/process.go @@ -0,0 +1,196 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/containerd/containerd (interfaces: Process) + +// Package mocks_container is a generated GoMock package. +package mocks_container + +import ( + context "context" + reflect "reflect" + syscall "syscall" + + containerd "github.com/containerd/containerd" + cio "github.com/containerd/containerd/cio" + gomock "github.com/golang/mock/gomock" +) + +// MockProcess is a mock of Process interface. +type MockProcess struct { + ctrl *gomock.Controller + recorder *MockProcessMockRecorder +} + +// MockProcessMockRecorder is the mock recorder for MockProcess. +type MockProcessMockRecorder struct { + mock *MockProcess +} + +// NewMockProcess creates a new mock instance. +func NewMockProcess(ctrl *gomock.Controller) *MockProcess { + mock := &MockProcess{ctrl: ctrl} + mock.recorder = &MockProcessMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProcess) EXPECT() *MockProcessMockRecorder { + return m.recorder +} + +// CloseIO mocks base method. +func (m *MockProcess) CloseIO(arg0 context.Context, arg1 ...containerd.IOCloserOpts) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CloseIO", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// CloseIO indicates an expected call of CloseIO. +func (mr *MockProcessMockRecorder) CloseIO(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseIO", reflect.TypeOf((*MockProcess)(nil).CloseIO), varargs...) +} + +// Delete mocks base method. +func (m *MockProcess) Delete(arg0 context.Context, arg1 ...containerd.ProcessDeleteOpts) (*containerd.ExitStatus, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(*containerd.ExitStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockProcessMockRecorder) Delete(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockProcess)(nil).Delete), varargs...) +} + +// ID mocks base method. +func (m *MockProcess) ID() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ID") + ret0, _ := ret[0].(string) + return ret0 +} + +// ID indicates an expected call of ID. +func (mr *MockProcessMockRecorder) ID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockProcess)(nil).ID)) +} + +// IO mocks base method. +func (m *MockProcess) IO() cio.IO { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IO") + ret0, _ := ret[0].(cio.IO) + return ret0 +} + +// IO indicates an expected call of IO. +func (mr *MockProcessMockRecorder) IO() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IO", reflect.TypeOf((*MockProcess)(nil).IO)) +} + +// Kill mocks base method. +func (m *MockProcess) Kill(arg0 context.Context, arg1 syscall.Signal, arg2 ...containerd.KillOpts) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Kill", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Kill indicates an expected call of Kill. +func (mr *MockProcessMockRecorder) Kill(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kill", reflect.TypeOf((*MockProcess)(nil).Kill), varargs...) +} + +// Pid mocks base method. +func (m *MockProcess) Pid() uint32 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Pid") + ret0, _ := ret[0].(uint32) + return ret0 +} + +// Pid indicates an expected call of Pid. +func (mr *MockProcessMockRecorder) Pid() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pid", reflect.TypeOf((*MockProcess)(nil).Pid)) +} + +// Resize mocks base method. +func (m *MockProcess) Resize(arg0 context.Context, arg1, arg2 uint32) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resize", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Resize indicates an expected call of Resize. +func (mr *MockProcessMockRecorder) Resize(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resize", reflect.TypeOf((*MockProcess)(nil).Resize), arg0, arg1, arg2) +} + +// Start mocks base method. +func (m *MockProcess) Start(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Start indicates an expected call of Start. +func (mr *MockProcessMockRecorder) Start(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockProcess)(nil).Start), arg0) +} + +// Status mocks base method. +func (m *MockProcess) Status(arg0 context.Context) (containerd.Status, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Status", arg0) + ret0, _ := ret[0].(containerd.Status) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Status indicates an expected call of Status. +func (mr *MockProcessMockRecorder) Status(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockProcess)(nil).Status), arg0) +} + +// Wait mocks base method. +func (m *MockProcess) Wait(arg0 context.Context) (<-chan containerd.ExitStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Wait", arg0) + ret0, _ := ret[0].(<-chan containerd.ExitStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Wait indicates an expected call of Wait. +func (mr *MockProcessMockRecorder) Wait(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockProcess)(nil).Wait), arg0) +} diff --git a/pkg/mocks/mocks_container/task.go b/pkg/mocks/mocks_container/task.go new file mode 100644 index 00000000..7f8e67ff --- /dev/null +++ b/pkg/mocks/mocks_container/task.go @@ -0,0 +1,340 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/containerd/containerd (interfaces: Task) + +// Package mocks_container is a generated GoMock package. +package mocks_container + +import ( + context "context" + reflect "reflect" + syscall "syscall" + + containerd "github.com/containerd/containerd" + types "github.com/containerd/containerd/api/types" + cio "github.com/containerd/containerd/cio" + gomock "github.com/golang/mock/gomock" + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +// MockTask is a mock of Task interface. +type MockTask struct { + ctrl *gomock.Controller + recorder *MockTaskMockRecorder +} + +// MockTaskMockRecorder is the mock recorder for MockTask. +type MockTaskMockRecorder struct { + mock *MockTask +} + +// NewMockTask creates a new mock instance. +func NewMockTask(ctrl *gomock.Controller) *MockTask { + mock := &MockTask{ctrl: ctrl} + mock.recorder = &MockTaskMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTask) EXPECT() *MockTaskMockRecorder { + return m.recorder +} + +// Checkpoint mocks base method. +func (m *MockTask) Checkpoint(arg0 context.Context, arg1 ...containerd.CheckpointTaskOpts) (containerd.Image, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Checkpoint", varargs...) + ret0, _ := ret[0].(containerd.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Checkpoint indicates an expected call of Checkpoint. +func (mr *MockTaskMockRecorder) Checkpoint(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Checkpoint", reflect.TypeOf((*MockTask)(nil).Checkpoint), varargs...) +} + +// CloseIO mocks base method. +func (m *MockTask) CloseIO(arg0 context.Context, arg1 ...containerd.IOCloserOpts) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CloseIO", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// CloseIO indicates an expected call of CloseIO. +func (mr *MockTaskMockRecorder) CloseIO(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseIO", reflect.TypeOf((*MockTask)(nil).CloseIO), varargs...) +} + +// Delete mocks base method. +func (m *MockTask) Delete(arg0 context.Context, arg1 ...containerd.ProcessDeleteOpts) (*containerd.ExitStatus, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(*containerd.ExitStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockTaskMockRecorder) Delete(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockTask)(nil).Delete), varargs...) +} + +// Exec mocks base method. +func (m *MockTask) Exec(arg0 context.Context, arg1 string, arg2 *specs.Process, arg3 cio.Creator) (containerd.Process, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Exec", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(containerd.Process) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Exec indicates an expected call of Exec. +func (mr *MockTaskMockRecorder) Exec(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockTask)(nil).Exec), arg0, arg1, arg2, arg3) +} + +// ID mocks base method. +func (m *MockTask) ID() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ID") + ret0, _ := ret[0].(string) + return ret0 +} + +// ID indicates an expected call of ID. +func (mr *MockTaskMockRecorder) ID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockTask)(nil).ID)) +} + +// IO mocks base method. +func (m *MockTask) IO() cio.IO { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IO") + ret0, _ := ret[0].(cio.IO) + return ret0 +} + +// IO indicates an expected call of IO. +func (mr *MockTaskMockRecorder) IO() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IO", reflect.TypeOf((*MockTask)(nil).IO)) +} + +// Kill mocks base method. +func (m *MockTask) Kill(arg0 context.Context, arg1 syscall.Signal, arg2 ...containerd.KillOpts) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Kill", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Kill indicates an expected call of Kill. +func (mr *MockTaskMockRecorder) Kill(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kill", reflect.TypeOf((*MockTask)(nil).Kill), varargs...) +} + +// LoadProcess mocks base method. +func (m *MockTask) LoadProcess(arg0 context.Context, arg1 string, arg2 cio.Attach) (containerd.Process, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProcess", arg0, arg1, arg2) + ret0, _ := ret[0].(containerd.Process) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProcess indicates an expected call of LoadProcess. +func (mr *MockTaskMockRecorder) LoadProcess(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProcess", reflect.TypeOf((*MockTask)(nil).LoadProcess), arg0, arg1, arg2) +} + +// Metrics mocks base method. +func (m *MockTask) Metrics(arg0 context.Context) (*types.Metric, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Metrics", arg0) + ret0, _ := ret[0].(*types.Metric) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Metrics indicates an expected call of Metrics. +func (mr *MockTaskMockRecorder) Metrics(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Metrics", reflect.TypeOf((*MockTask)(nil).Metrics), arg0) +} + +// Pause mocks base method. +func (m *MockTask) Pause(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Pause", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Pause indicates an expected call of Pause. +func (mr *MockTaskMockRecorder) Pause(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pause", reflect.TypeOf((*MockTask)(nil).Pause), arg0) +} + +// Pid mocks base method. +func (m *MockTask) Pid() uint32 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Pid") + ret0, _ := ret[0].(uint32) + return ret0 +} + +// Pid indicates an expected call of Pid. +func (mr *MockTaskMockRecorder) Pid() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pid", reflect.TypeOf((*MockTask)(nil).Pid)) +} + +// Pids mocks base method. +func (m *MockTask) Pids(arg0 context.Context) ([]containerd.ProcessInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Pids", arg0) + ret0, _ := ret[0].([]containerd.ProcessInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Pids indicates an expected call of Pids. +func (mr *MockTaskMockRecorder) Pids(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pids", reflect.TypeOf((*MockTask)(nil).Pids), arg0) +} + +// Resize mocks base method. +func (m *MockTask) Resize(arg0 context.Context, arg1, arg2 uint32) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resize", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Resize indicates an expected call of Resize. +func (mr *MockTaskMockRecorder) Resize(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resize", reflect.TypeOf((*MockTask)(nil).Resize), arg0, arg1, arg2) +} + +// Resume mocks base method. +func (m *MockTask) Resume(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resume", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Resume indicates an expected call of Resume. +func (mr *MockTaskMockRecorder) Resume(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resume", reflect.TypeOf((*MockTask)(nil).Resume), arg0) +} + +// Spec mocks base method. +func (m *MockTask) Spec(arg0 context.Context) (*specs.Spec, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Spec", arg0) + ret0, _ := ret[0].(*specs.Spec) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Spec indicates an expected call of Spec. +func (mr *MockTaskMockRecorder) Spec(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Spec", reflect.TypeOf((*MockTask)(nil).Spec), arg0) +} + +// Start mocks base method. +func (m *MockTask) Start(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Start indicates an expected call of Start. +func (mr *MockTaskMockRecorder) Start(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockTask)(nil).Start), arg0) +} + +// Status mocks base method. +func (m *MockTask) Status(arg0 context.Context) (containerd.Status, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Status", arg0) + ret0, _ := ret[0].(containerd.Status) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Status indicates an expected call of Status. +func (mr *MockTaskMockRecorder) Status(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockTask)(nil).Status), arg0) +} + +// Update mocks base method. +func (m *MockTask) Update(arg0 context.Context, arg1 ...containerd.UpdateTaskOpts) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockTaskMockRecorder) Update(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockTask)(nil).Update), varargs...) +} + +// Wait mocks base method. +func (m *MockTask) Wait(arg0 context.Context) (<-chan containerd.ExitStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Wait", arg0) + ret0, _ := ret[0].(<-chan containerd.ExitStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Wait indicates an expected call of Wait. +func (mr *MockTaskMockRecorder) Wait(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockTask)(nil).Wait), arg0) +} diff --git a/pkg/mocks/mocks_ecc/execcmd.go b/pkg/mocks/mocks_ecc/execcmd.go new file mode 100644 index 00000000..f7a185db --- /dev/null +++ b/pkg/mocks/mocks_ecc/execcmd.go @@ -0,0 +1,111 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/ecc (interfaces: ExecCmd) + +// Package mocks_ecc is a generated GoMock package. +package mocks_ecc + +import ( + io "io" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockExecCmd is a mock of ExecCmd interface. +type MockExecCmd struct { + ctrl *gomock.Controller + recorder *MockExecCmdMockRecorder +} + +// MockExecCmdMockRecorder is the mock recorder for MockExecCmd. +type MockExecCmdMockRecorder struct { + mock *MockExecCmd +} + +// NewMockExecCmd creates a new mock instance. +func NewMockExecCmd(ctrl *gomock.Controller) *MockExecCmd { + mock := &MockExecCmd{ctrl: ctrl} + mock.recorder = &MockExecCmdMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExecCmd) EXPECT() *MockExecCmdMockRecorder { + return m.recorder +} + +// GetDir mocks base method. +func (m *MockExecCmd) GetDir() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDir") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetDir indicates an expected call of GetDir. +func (mr *MockExecCmdMockRecorder) GetDir() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDir", reflect.TypeOf((*MockExecCmd)(nil).GetDir)) +} + +// Run mocks base method. +func (m *MockExecCmd) Run() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Run") + ret0, _ := ret[0].(error) + return ret0 +} + +// Run indicates an expected call of Run. +func (mr *MockExecCmdMockRecorder) Run() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockExecCmd)(nil).Run)) +} + +// SetDir mocks base method. +func (m *MockExecCmd) SetDir(arg0 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetDir", arg0) +} + +// SetDir indicates an expected call of SetDir. +func (mr *MockExecCmdMockRecorder) SetDir(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDir", reflect.TypeOf((*MockExecCmd)(nil).SetDir), arg0) +} + +// SetStderr mocks base method. +func (m *MockExecCmd) SetStderr(arg0 io.Writer) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetStderr", arg0) +} + +// SetStderr indicates an expected call of SetStderr. +func (mr *MockExecCmdMockRecorder) SetStderr(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStderr", reflect.TypeOf((*MockExecCmd)(nil).SetStderr), arg0) +} + +// SetStdin mocks base method. +func (m *MockExecCmd) SetStdin(arg0 io.Reader) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetStdin", arg0) +} + +// SetStdin indicates an expected call of SetStdin. +func (mr *MockExecCmdMockRecorder) SetStdin(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStdin", reflect.TypeOf((*MockExecCmd)(nil).SetStdin), arg0) +} + +// SetStdout mocks base method. +func (m *MockExecCmd) SetStdout(arg0 io.Writer) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetStdout", arg0) +} + +// SetStdout indicates an expected call of SetStdout. +func (mr *MockExecCmdMockRecorder) SetStdout(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStdout", reflect.TypeOf((*MockExecCmd)(nil).SetStdout), arg0) +} diff --git a/pkg/mocks/mocks_ecc/execcmdcreator.go b/pkg/mocks/mocks_ecc/execcmdcreator.go new file mode 100644 index 00000000..ea590d12 --- /dev/null +++ b/pkg/mocks/mocks_ecc/execcmdcreator.go @@ -0,0 +1,54 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/ecc (interfaces: ExecCmdCreator) + +// Package mocks_ecc is a generated GoMock package. +package mocks_ecc + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + ecc "github.com/runfinch/finch-daemon/pkg/ecc" +) + +// MockExecCmdCreator is a mock of ExecCmdCreator interface. +type MockExecCmdCreator struct { + ctrl *gomock.Controller + recorder *MockExecCmdCreatorMockRecorder +} + +// MockExecCmdCreatorMockRecorder is the mock recorder for MockExecCmdCreator. +type MockExecCmdCreatorMockRecorder struct { + mock *MockExecCmdCreator +} + +// NewMockExecCmdCreator creates a new mock instance. +func NewMockExecCmdCreator(ctrl *gomock.Controller) *MockExecCmdCreator { + mock := &MockExecCmdCreator{ctrl: ctrl} + mock.recorder = &MockExecCmdCreatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExecCmdCreator) EXPECT() *MockExecCmdCreatorMockRecorder { + return m.recorder +} + +// Command mocks base method. +func (m *MockExecCmdCreator) Command(arg0 string, arg1 ...string) ecc.ExecCmd { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Command", varargs...) + ret0, _ := ret[0].(ecc.ExecCmd) + return ret0 +} + +// Command indicates an expected call of Command. +func (mr *MockExecCmdCreatorMockRecorder) Command(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Command", reflect.TypeOf((*MockExecCmdCreator)(nil).Command), varargs...) +} diff --git a/pkg/mocks/mocks_exec/execsvc.go b/pkg/mocks/mocks_exec/execsvc.go new file mode 100644 index 00000000..b2fa7db5 --- /dev/null +++ b/pkg/mocks/mocks_exec/execsvc.go @@ -0,0 +1,79 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/api/handlers/exec (interfaces: Service) + +// Package mocks_exec is a generated GoMock package. +package mocks_exec + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + types "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Inspect mocks base method. +func (m *MockService) Inspect(arg0 context.Context, arg1, arg2 string) (*types.ExecInspect, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", arg0, arg1, arg2) + ret0, _ := ret[0].(*types.ExecInspect) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockServiceMockRecorder) Inspect(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockService)(nil).Inspect), arg0, arg1, arg2) +} + +// Resize mocks base method. +func (m *MockService) Resize(arg0 context.Context, arg1 *types.ExecResizeOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resize", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Resize indicates an expected call of Resize. +func (mr *MockServiceMockRecorder) Resize(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resize", reflect.TypeOf((*MockService)(nil).Resize), arg0, arg1) +} + +// Start mocks base method. +func (m *MockService) Start(arg0 context.Context, arg1 *types.ExecStartOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Start indicates an expected call of Start. +func (mr *MockServiceMockRecorder) Start(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockService)(nil).Start), arg0, arg1) +} diff --git a/pkg/mocks/mocks_http/conn.go b/pkg/mocks/mocks_http/conn.go new file mode 100644 index 00000000..679c27fd --- /dev/null +++ b/pkg/mocks/mocks_http/conn.go @@ -0,0 +1,150 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: net (interfaces: Conn) + +// Package mocks_http is a generated GoMock package. +package mocks_http + +import ( + net "net" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" +) + +// MockConn is a mock of Conn interface. +type MockConn struct { + ctrl *gomock.Controller + recorder *MockConnMockRecorder +} + +// MockConnMockRecorder is the mock recorder for MockConn. +type MockConnMockRecorder struct { + mock *MockConn +} + +// NewMockConn creates a new mock instance. +func NewMockConn(ctrl *gomock.Controller) *MockConn { + mock := &MockConn{ctrl: ctrl} + mock.recorder = &MockConnMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockConn) EXPECT() *MockConnMockRecorder { + return m.recorder +} + +// Close mocks base method. +func (m *MockConn) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockConnMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockConn)(nil).Close)) +} + +// LocalAddr mocks base method. +func (m *MockConn) LocalAddr() net.Addr { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LocalAddr") + ret0, _ := ret[0].(net.Addr) + return ret0 +} + +// LocalAddr indicates an expected call of LocalAddr. +func (mr *MockConnMockRecorder) LocalAddr() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LocalAddr", reflect.TypeOf((*MockConn)(nil).LocalAddr)) +} + +// Read mocks base method. +func (m *MockConn) Read(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Read", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Read indicates an expected call of Read. +func (mr *MockConnMockRecorder) Read(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockConn)(nil).Read), arg0) +} + +// RemoteAddr mocks base method. +func (m *MockConn) RemoteAddr() net.Addr { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoteAddr") + ret0, _ := ret[0].(net.Addr) + return ret0 +} + +// RemoteAddr indicates an expected call of RemoteAddr. +func (mr *MockConnMockRecorder) RemoteAddr() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoteAddr", reflect.TypeOf((*MockConn)(nil).RemoteAddr)) +} + +// SetDeadline mocks base method. +func (m *MockConn) SetDeadline(arg0 time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetDeadline", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetDeadline indicates an expected call of SetDeadline. +func (mr *MockConnMockRecorder) SetDeadline(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDeadline", reflect.TypeOf((*MockConn)(nil).SetDeadline), arg0) +} + +// SetReadDeadline mocks base method. +func (m *MockConn) SetReadDeadline(arg0 time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetReadDeadline", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetReadDeadline indicates an expected call of SetReadDeadline. +func (mr *MockConnMockRecorder) SetReadDeadline(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetReadDeadline", reflect.TypeOf((*MockConn)(nil).SetReadDeadline), arg0) +} + +// SetWriteDeadline mocks base method. +func (m *MockConn) SetWriteDeadline(arg0 time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetWriteDeadline", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetWriteDeadline indicates an expected call of SetWriteDeadline. +func (mr *MockConnMockRecorder) SetWriteDeadline(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetWriteDeadline", reflect.TypeOf((*MockConn)(nil).SetWriteDeadline), arg0) +} + +// Write mocks base method. +func (m *MockConn) Write(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Write", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Write indicates an expected call of Write. +func (mr *MockConnMockRecorder) Write(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockConn)(nil).Write), arg0) +} diff --git a/pkg/mocks/mocks_http/response_writer.go b/pkg/mocks/mocks_http/response_writer.go new file mode 100644 index 00000000..d1f4e381 --- /dev/null +++ b/pkg/mocks/mocks_http/response_writer.go @@ -0,0 +1,76 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: net/http (interfaces: ResponseWriter) + +// Package mocks_http is a generated GoMock package. +package mocks_http + +import ( + http "net/http" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockResponseWriter is a mock of ResponseWriter interface. +type MockResponseWriter struct { + ctrl *gomock.Controller + recorder *MockResponseWriterMockRecorder +} + +// MockResponseWriterMockRecorder is the mock recorder for MockResponseWriter. +type MockResponseWriterMockRecorder struct { + mock *MockResponseWriter +} + +// NewMockResponseWriter creates a new mock instance. +func NewMockResponseWriter(ctrl *gomock.Controller) *MockResponseWriter { + mock := &MockResponseWriter{ctrl: ctrl} + mock.recorder = &MockResponseWriterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockResponseWriter) EXPECT() *MockResponseWriterMockRecorder { + return m.recorder +} + +// Header mocks base method. +func (m *MockResponseWriter) Header() http.Header { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Header") + ret0, _ := ret[0].(http.Header) + return ret0 +} + +// Header indicates an expected call of Header. +func (mr *MockResponseWriterMockRecorder) Header() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Header", reflect.TypeOf((*MockResponseWriter)(nil).Header)) +} + +// Write mocks base method. +func (m *MockResponseWriter) Write(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Write", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Write indicates an expected call of Write. +func (mr *MockResponseWriterMockRecorder) Write(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockResponseWriter)(nil).Write), arg0) +} + +// WriteHeader mocks base method. +func (m *MockResponseWriter) WriteHeader(arg0 int) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "WriteHeader", arg0) +} + +// WriteHeader indicates an expected call of WriteHeader. +func (mr *MockResponseWriterMockRecorder) WriteHeader(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteHeader", reflect.TypeOf((*MockResponseWriter)(nil).WriteHeader), arg0) +} diff --git a/pkg/mocks/mocks_image/imagesvc.go b/pkg/mocks/mocks_image/imagesvc.go new file mode 100644 index 00000000..951bd90b --- /dev/null +++ b/pkg/mocks/mocks_image/imagesvc.go @@ -0,0 +1,128 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/api/handlers/image (interfaces: Service) + +// Package mocks_image is a generated GoMock package. +package mocks_image + +import ( + context "context" + io "io" + reflect "reflect" + + dockercompat "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + types "github.com/docker/cli/cli/config/types" + gomock "github.com/golang/mock/gomock" + types0 "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Inspect mocks base method. +func (m *MockService) Inspect(arg0 context.Context, arg1 string) (*dockercompat.Image, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", arg0, arg1) + ret0, _ := ret[0].(*dockercompat.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockServiceMockRecorder) Inspect(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockService)(nil).Inspect), arg0, arg1) +} + +// List mocks base method. +func (m *MockService) List(arg0 context.Context) ([]types0.ImageSummary, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0) + ret0, _ := ret[0].([]types0.ImageSummary) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockServiceMockRecorder) List(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockService)(nil).List), arg0) +} + +// Pull mocks base method. +func (m *MockService) Pull(arg0 context.Context, arg1, arg2, arg3 string, arg4 *types.AuthConfig, arg5 io.Writer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Pull", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(error) + return ret0 +} + +// Pull indicates an expected call of Pull. +func (mr *MockServiceMockRecorder) Pull(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pull", reflect.TypeOf((*MockService)(nil).Pull), arg0, arg1, arg2, arg3, arg4, arg5) +} + +// Push mocks base method. +func (m *MockService) Push(arg0 context.Context, arg1, arg2 string, arg3 *types.AuthConfig, arg4 io.Writer) (*types0.PushResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Push", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*types0.PushResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Push indicates an expected call of Push. +func (mr *MockServiceMockRecorder) Push(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockService)(nil).Push), arg0, arg1, arg2, arg3, arg4) +} + +// Remove mocks base method. +func (m *MockService) Remove(arg0 context.Context, arg1 string, arg2 bool) ([]string, []string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", arg0, arg1, arg2) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].([]string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Remove indicates an expected call of Remove. +func (mr *MockServiceMockRecorder) Remove(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockService)(nil).Remove), arg0, arg1, arg2) +} + +// Tag mocks base method. +func (m *MockService) Tag(arg0 context.Context, arg1, arg2, arg3 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Tag", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// Tag indicates an expected call of Tag. +func (mr *MockServiceMockRecorder) Tag(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Tag", reflect.TypeOf((*MockService)(nil).Tag), arg0, arg1, arg2, arg3) +} diff --git a/pkg/mocks/mocks_image/store.go b/pkg/mocks/mocks_image/store.go new file mode 100644 index 00000000..7895c3f4 --- /dev/null +++ b/pkg/mocks/mocks_image/store.go @@ -0,0 +1,125 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/containerd/containerd/images (interfaces: Store) + +// Package mocks_image is a generated GoMock package. +package mocks_image + +import ( + context "context" + reflect "reflect" + + images "github.com/containerd/containerd/images" + gomock "github.com/golang/mock/gomock" +) + +// MockStore is a mock of Store interface. +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance. +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockStore) Create(arg0 context.Context, arg1 images.Image) (images.Image, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(images.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockStoreMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockStore)(nil).Create), arg0, arg1) +} + +// Delete mocks base method. +func (m *MockStore) Delete(arg0 context.Context, arg1 string, arg2 ...images.DeleteOpt) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockStoreMockRecorder) Delete(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStore)(nil).Delete), varargs...) +} + +// Get mocks base method. +func (m *MockStore) Get(arg0 context.Context, arg1 string) (images.Image, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(images.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockStoreMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStore)(nil).Get), arg0, arg1) +} + +// List mocks base method. +func (m *MockStore) List(arg0 context.Context, arg1 ...string) ([]images.Image, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].([]images.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockStoreMockRecorder) List(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockStore)(nil).List), varargs...) +} + +// Update mocks base method. +func (m *MockStore) Update(arg0 context.Context, arg1 images.Image, arg2 ...string) (images.Image, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(images.Image) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockStoreMockRecorder) Update(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockStore)(nil).Update), varargs...) +} diff --git a/pkg/mocks/mocks_logger/logger.go b/pkg/mocks/mocks_logger/logger.go new file mode 100644 index 00000000..5fd986a4 --- /dev/null +++ b/pkg/mocks/mocks_logger/logger.go @@ -0,0 +1,227 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/flog (interfaces: Logger) + +// Package mocks_logger is a generated GoMock package. +package mocks_logger + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + flog "github.com/runfinch/finch-daemon/pkg/flog" +) + +// Logger is a mock of Logger interface. +type Logger struct { + ctrl *gomock.Controller + recorder *LoggerMockRecorder +} + +// LoggerMockRecorder is the mock recorder for Logger. +type LoggerMockRecorder struct { + mock *Logger +} + +// NewLogger creates a new mock instance. +func NewLogger(ctrl *gomock.Controller) *Logger { + mock := &Logger{ctrl: ctrl} + mock.recorder = &LoggerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Logger) EXPECT() *LoggerMockRecorder { + return m.recorder +} + +// Debugf mocks base method. +func (m *Logger) Debugf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Debugf", varargs...) +} + +// Debugf indicates an expected call of Debugf. +func (mr *LoggerMockRecorder) Debugf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*Logger)(nil).Debugf), varargs...) +} + +// Debugln mocks base method. +func (m *Logger) Debugln(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Debugln", varargs...) +} + +// Debugln indicates an expected call of Debugln. +func (mr *LoggerMockRecorder) Debugln(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugln", reflect.TypeOf((*Logger)(nil).Debugln), arg0...) +} + +// Error mocks base method. +func (m *Logger) Error(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Error", varargs...) +} + +// Error indicates an expected call of Error. +func (mr *LoggerMockRecorder) Error(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*Logger)(nil).Error), arg0...) +} + +// Errorf mocks base method. +func (m *Logger) Errorf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Errorf", varargs...) +} + +// Errorf indicates an expected call of Errorf. +func (mr *LoggerMockRecorder) Errorf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Errorf", reflect.TypeOf((*Logger)(nil).Errorf), varargs...) +} + +// Fatal mocks base method. +func (m *Logger) Fatal(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Fatal", varargs...) +} + +// Fatal indicates an expected call of Fatal. +func (mr *LoggerMockRecorder) Fatal(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fatal", reflect.TypeOf((*Logger)(nil).Fatal), arg0...) +} + +// Info mocks base method. +func (m *Logger) Info(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Info", varargs...) +} + +// Info indicates an expected call of Info. +func (mr *LoggerMockRecorder) Info(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*Logger)(nil).Info), arg0...) +} + +// Infof mocks base method. +func (m *Logger) Infof(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Infof", varargs...) +} + +// Infof indicates an expected call of Infof. +func (mr *LoggerMockRecorder) Infof(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*Logger)(nil).Infof), varargs...) +} + +// Infoln mocks base method. +func (m *Logger) Infoln(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Infoln", varargs...) +} + +// Infoln indicates an expected call of Infoln. +func (mr *LoggerMockRecorder) Infoln(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infoln", reflect.TypeOf((*Logger)(nil).Infoln), arg0...) +} + +// SetLevel mocks base method. +func (m *Logger) SetLevel(arg0 flog.Level) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetLevel", arg0) +} + +// SetLevel indicates an expected call of SetLevel. +func (mr *LoggerMockRecorder) SetLevel(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLevel", reflect.TypeOf((*Logger)(nil).SetLevel), arg0) +} + +// Warn mocks base method. +func (m *Logger) Warn(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warn", varargs...) +} + +// Warn indicates an expected call of Warn. +func (mr *LoggerMockRecorder) Warn(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*Logger)(nil).Warn), arg0...) +} + +// Warnf mocks base method. +func (m *Logger) Warnf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnf", varargs...) +} + +// Warnf indicates an expected call of Warnf. +func (mr *LoggerMockRecorder) Warnf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*Logger)(nil).Warnf), varargs...) +} + +// Warnln mocks base method. +func (m *Logger) Warnln(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnln", varargs...) +} + +// Warnln indicates an expected call of Warnln. +func (mr *LoggerMockRecorder) Warnln(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnln", reflect.TypeOf((*Logger)(nil).Warnln), arg0...) +} diff --git a/pkg/mocks/mocks_network/networksvc.go b/pkg/mocks/mocks_network/networksvc.go new file mode 100644 index 00000000..3a34aad8 --- /dev/null +++ b/pkg/mocks/mocks_network/networksvc.go @@ -0,0 +1,109 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/api/handlers/network (interfaces: Service) + +// Package mocks_network is a generated GoMock package. +package mocks_network + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + types "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Connect mocks base method. +func (m *MockService) Connect(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Connect", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Connect indicates an expected call of Connect. +func (mr *MockServiceMockRecorder) Connect(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockService)(nil).Connect), arg0, arg1, arg2) +} + +// Create mocks base method. +func (m *MockService) Create(arg0 context.Context, arg1 types.NetworkCreateRequest) (types.NetworkCreateResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(types.NetworkCreateResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockServiceMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockService)(nil).Create), arg0, arg1) +} + +// Inspect mocks base method. +func (m *MockService) Inspect(arg0 context.Context, arg1 string) (*types.NetworkInspectResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", arg0, arg1) + ret0, _ := ret[0].(*types.NetworkInspectResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockServiceMockRecorder) Inspect(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockService)(nil).Inspect), arg0, arg1) +} + +// List mocks base method. +func (m *MockService) List(arg0 context.Context) ([]*types.NetworkInspectResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0) + ret0, _ := ret[0].([]*types.NetworkInspectResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockServiceMockRecorder) List(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockService)(nil).List), arg0) +} + +// Remove mocks base method. +func (m *MockService) Remove(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Remove indicates an expected call of Remove. +func (mr *MockServiceMockRecorder) Remove(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockService)(nil).Remove), arg0, arg1) +} diff --git a/pkg/mocks/mocks_statsutil/statsutil.go b/pkg/mocks/mocks_statsutil/statsutil.go new file mode 100644 index 00000000..2acf3fca --- /dev/null +++ b/pkg/mocks/mocks_statsutil/statsutil.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/statsutil (interfaces: StatsUtil) + +// Package mocks_statsutil is a generated GoMock package. +package mocks_statsutil + +import ( + reflect "reflect" + + native "github.com/containerd/nerdctl/pkg/inspecttypes/native" + types "github.com/docker/docker/api/types" + gomock "github.com/golang/mock/gomock" +) + +// MockStatsUtil is a mock of StatsUtil interface. +type MockStatsUtil struct { + ctrl *gomock.Controller + recorder *MockStatsUtilMockRecorder +} + +// MockStatsUtilMockRecorder is the mock recorder for MockStatsUtil. +type MockStatsUtilMockRecorder struct { + mock *MockStatsUtil +} + +// NewMockStatsUtil creates a new mock instance. +func NewMockStatsUtil(ctrl *gomock.Controller) *MockStatsUtil { + mock := &MockStatsUtil{ctrl: ctrl} + mock.recorder = &MockStatsUtilMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStatsUtil) EXPECT() *MockStatsUtilMockRecorder { + return m.recorder +} + +// CollectNetworkStats mocks base method. +func (m *MockStatsUtil) CollectNetworkStats(arg0 int, arg1 []native.NetInterface) (map[string]types.NetworkStats, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CollectNetworkStats", arg0, arg1) + ret0, _ := ret[0].(map[string]types.NetworkStats) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CollectNetworkStats indicates an expected call of CollectNetworkStats. +func (mr *MockStatsUtilMockRecorder) CollectNetworkStats(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectNetworkStats", reflect.TypeOf((*MockStatsUtil)(nil).CollectNetworkStats), arg0, arg1) +} + +// GetNumberOnlineCPUs mocks base method. +func (m *MockStatsUtil) GetNumberOnlineCPUs() (uint32, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNumberOnlineCPUs") + ret0, _ := ret[0].(uint32) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNumberOnlineCPUs indicates an expected call of GetNumberOnlineCPUs. +func (mr *MockStatsUtilMockRecorder) GetNumberOnlineCPUs() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNumberOnlineCPUs", reflect.TypeOf((*MockStatsUtil)(nil).GetNumberOnlineCPUs)) +} + +// GetSystemCPUUsage mocks base method. +func (m *MockStatsUtil) GetSystemCPUUsage() (uint64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSystemCPUUsage") + ret0, _ := ret[0].(uint64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSystemCPUUsage indicates an expected call of GetSystemCPUUsage. +func (mr *MockStatsUtilMockRecorder) GetSystemCPUUsage() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSystemCPUUsage", reflect.TypeOf((*MockStatsUtil)(nil).GetSystemCPUUsage)) +} diff --git a/pkg/mocks/mocks_system/systemsvc.go b/pkg/mocks/mocks_system/systemsvc.go new file mode 100644 index 00000000..ae986c9c --- /dev/null +++ b/pkg/mocks/mocks_system/systemsvc.go @@ -0,0 +1,99 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/api/handlers/system (interfaces: Service) + +// Package mocks_system is a generated GoMock package. +package mocks_system + +import ( + context "context" + reflect "reflect" + + config "github.com/containerd/nerdctl/pkg/config" + dockercompat "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + gomock "github.com/golang/mock/gomock" + events "github.com/runfinch/finch-daemon/pkg/api/events" + types "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Auth mocks base method. +func (m *MockService) Auth(arg0 context.Context, arg1, arg2, arg3 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Auth", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Auth indicates an expected call of Auth. +func (mr *MockServiceMockRecorder) Auth(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Auth", reflect.TypeOf((*MockService)(nil).Auth), arg0, arg1, arg2, arg3) +} + +// GetInfo mocks base method. +func (m *MockService) GetInfo(arg0 context.Context, arg1 *config.Config) (*dockercompat.Info, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInfo", arg0, arg1) + ret0, _ := ret[0].(*dockercompat.Info) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetInfo indicates an expected call of GetInfo. +func (mr *MockServiceMockRecorder) GetInfo(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInfo", reflect.TypeOf((*MockService)(nil).GetInfo), arg0, arg1) +} + +// GetVersion mocks base method. +func (m *MockService) GetVersion(arg0 context.Context) (*types.VersionInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVersion", arg0) + ret0, _ := ret[0].(*types.VersionInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetVersion indicates an expected call of GetVersion. +func (mr *MockServiceMockRecorder) GetVersion(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockService)(nil).GetVersion), arg0) +} + +// SubscribeEvents mocks base method. +func (m *MockService) SubscribeEvents(arg0 context.Context, arg1 map[string][]string) (<-chan *events.Event, <-chan error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubscribeEvents", arg0, arg1) + ret0, _ := ret[0].(<-chan *events.Event) + ret1, _ := ret[1].(<-chan error) + return ret0, ret1 +} + +// SubscribeEvents indicates an expected call of SubscribeEvents. +func (mr *MockServiceMockRecorder) SubscribeEvents(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubscribeEvents", reflect.TypeOf((*MockService)(nil).SubscribeEvents), arg0, arg1) +} diff --git a/pkg/mocks/mocks_volume/volumesvc.go b/pkg/mocks/mocks_volume/volumesvc.go new file mode 100644 index 00000000..14f76f5c --- /dev/null +++ b/pkg/mocks/mocks_volume/volumesvc.go @@ -0,0 +1,96 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/pkg/api/handlers/volume (interfaces: Service) + +// Package mocks_volume is a generated GoMock package. +package mocks_volume + +import ( + context "context" + reflect "reflect" + + native "github.com/containerd/nerdctl/pkg/inspecttypes/native" + gomock "github.com/golang/mock/gomock" + types "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockService) Create(arg0 context.Context, arg1 string, arg2 []string) (*native.Volume, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2) + ret0, _ := ret[0].(*native.Volume) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockServiceMockRecorder) Create(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockService)(nil).Create), arg0, arg1, arg2) +} + +// Inspect mocks base method. +func (m *MockService) Inspect(arg0 string) (*native.Volume, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", arg0) + ret0, _ := ret[0].(*native.Volume) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockServiceMockRecorder) Inspect(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockService)(nil).Inspect), arg0) +} + +// List mocks base method. +func (m *MockService) List(arg0 context.Context, arg1 []string) (*types.VolumesListResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0, arg1) + ret0, _ := ret[0].(*types.VolumesListResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockServiceMockRecorder) List(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockService)(nil).List), arg0, arg1) +} + +// Remove mocks base method. +func (m *MockService) Remove(arg0 context.Context, arg1 string, arg2 bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Remove indicates an expected call of Remove. +func (mr *MockServiceMockRecorder) Remove(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockService)(nil).Remove), arg0, arg1, arg2) +} diff --git a/pkg/service/builder/build.go b/pkg/service/builder/build.go new file mode 100644 index 00000000..b3c61a4f --- /dev/null +++ b/pkg/service/builder/build.go @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package builder + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + + "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +const tagEventAction = "tag" + +// setting publishTagEventFunc as a variable to allow mocking this function for unit testing +var publishTagEventFunc = (*service).publishTagEvent + +// Build function builds an image using nerdctl function based on the BuilderBuildOptions +func (s *service) Build(ctx context.Context, options *ncTypes.BuilderBuildOptions, tarBody io.ReadCloser) ([]types.BuildResult, error) { + tarCmd, err := s.tarExtractor.ExtractInTemp(tarBody, "build-context") + if err != nil { + s.logger.Warnf("Failed to extract build context. Error: %v", err) + return nil, err + } + dir := tarCmd.GetDir() + + // create an in-memory writer for the stderr + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + tarCmd.SetStderr(writer) + // clean up the directory before exiting the method + defer s.tarExtractor.Cleanup(tarCmd) + // execute the extract command + if err = tarCmd.Run(); err != nil { + s.logger.Warnf("Failed to extract build context in temp folder. Dir: %s, Error: %s, Stderr: %s", + dir, err.Error(), buf.String()) + return nil, fmt.Errorf("failed to extract build context in temp folder") + } + + // update the build context and the docker file path with the temp dir. + options.BuildContext = dir + options.File = fmt.Sprintf("%s/%s", dir, options.File) + if err = s.nctlBuilderSvc.Build(ctx, s.client, *options); err != nil { + return nil, err + } + + // publish tag event for the built image + result := []types.BuildResult{} + if options.Tag != nil { + for _, tag := range options.Tag { + tagEvent, err := publishTagEventFunc(s, ctx, tag) + if err != nil { + return nil, err + } + result = append(result, types.BuildResult{ID: tagEvent.ID}) + options.Stdout.Write([]byte(fmt.Sprintf("Successfully built %s\n", tagEvent.ID))) + } + } + + return result, nil +} + +func (s *service) publishTagEvent(ctx context.Context, tag string) (*events.Event, error) { + _, uniqueCount, images, err := s.nctlBuilderSvc.SearchImage(ctx, tag) + if err != nil { + return nil, err + } + if uniqueCount == 0 || len(images) == 0 { + return nil, errdefs.NewNotFound(fmt.Errorf("no such image: %s", tag)) + } + if uniqueCount != 1 { + return nil, fmt.Errorf("multiple images exist with tag %s", tag) + } + + tagEvent := getTagEvent(images[0].Target.Digest.String(), tag) + if err = s.client.PublishEvent(ctx, tagTopic(), tagEvent); err != nil { + return nil, fmt.Errorf("failed to publish tag event for image %s: %s", tag, err) + } + return tagEvent, nil +} + +func tagTopic() string { + return fmt.Sprintf("/%s/%s/%s", events.CompatibleTopicPrefix, "image", tagEventAction) +} + +func getTagEvent(digest, imgName string) *events.Event { + return &events.Event{ + ID: digest, + Status: tagEventAction, + Type: "image", + Action: tagEventAction, + Actor: events.EventActor{ + Id: digest, + Attributes: map[string]string{ + "name": imgName, + }, + }, + } +} diff --git a/pkg/service/builder/build_test.go b/pkg/service/builder/build_test.go new file mode 100644 index 00000000..ac0223e2 --- /dev/null +++ b/pkg/service/builder/build_test.go @@ -0,0 +1,174 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package builder + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + + "github.com/containerd/nerdctl/pkg/api/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/runfinch/finch-daemon/pkg/api/handlers/builder" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_ecc" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to Build API +var _ = Describe("Build API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + tarExtractor *mocks_archive.MockTarExtractor + mockCmd *mocks_ecc.MockExecCmd + cdClient *mocks_backend.MockContainerdClient + ncBuilderSvc *mocks_backend.MockNerdctlBuilderSvc + ncImgSvc *mocks_backend.MockNerdctlImageSvc + con *mocks_container.MockContainer + cid string + service builder.Service + buildOption types.BuilderBuildOptions + req *http.Request + rr *httptest.ResponseRecorder + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + rr = httptest.NewRecorder() + logger = mocks_logger.NewLogger(mockCtrl) + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + mockCmd = mocks_ecc.NewMockExecCmd(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncBuilderSvc = mocks_backend.NewMockNerdctlBuilderSvc(mockCtrl) + ncImgSvc = mocks_backend.NewMockNerdctlImageSvc(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + mockCmd.EXPECT().GetDir().Return(fmt.Sprintf("%s/%s", os.TempDir(), cid)).AnyTimes() + mockCmd.EXPECT().SetStderr(gomock.Any()).AnyTimes() + + service = NewService(cdClient, mockNerdctlService{ncBuilderSvc, ncImgSvc}, logger, tarExtractor) + buildOption = types.BuilderBuildOptions{} + req, _ = http.NewRequest(http.MethodPost, "/build", nil) + }) + Context("service", func() { + It("should successfully build image", func() { + // set up the mock + ncBuilderSvc.EXPECT().Build(gomock.Any(), gomock.Any(), gomock.Any()) + tarExtractor.EXPECT().ExtractInTemp(gomock.Any(), gomock.Any()). + Return(mockCmd, nil) + tarExtractor.EXPECT().Cleanup(gomock.Any()) + mockCmd.EXPECT().Run().Return(nil) + + // service should not return any error + _, err := service.Build(ctx, &buildOption, req.Body) + Expect(err).Should(BeNil()) + }) + It("should fail building image due to temp folder creation failure", func() { + // set up the mock + mockErr := fmt.Errorf("failed to create temp folder") + tarExtractor.EXPECT().ExtractInTemp(gomock.Any(), gomock.Any()).Return(nil, mockErr) + logger.EXPECT().Warnf("Failed to extract build context. Error: %v", mockErr) + // service should return error + _, err := service.Build(ctx, &buildOption, req.Body) + Expect(err).Should(Equal(mockErr)) + }) + It("should fail building image due to tar extraction failure", func() { + // set up the mock + mockCmd.EXPECT().Run().Return(fmt.Errorf("tar error")) + tarExtractor.EXPECT().ExtractInTemp(gomock.Any(), gomock.Any()).Return(mockCmd, nil) + tarExtractor.EXPECT().Cleanup(gomock.Any()) + logger.EXPECT().Warnf("Failed to extract build context in temp folder. Dir: %s, Error: %s, Stderr: %s", + gomock.Any(), gomock.Any(), gomock.Any()) + // service should return error + _, err := service.Build(ctx, &buildOption, req.Body) + Expect(err).Should(Not(BeNil())) + Expect(err.Error()).Should(Equal("failed to extract build context in temp folder")) + }) + It("should fail building image due create image", func() { + // set up the mock + mockCmd.EXPECT().Run().Return(fmt.Errorf("tar error")) + tarExtractor.EXPECT().ExtractInTemp(gomock.Any(), gomock.Any()).Return(mockCmd, nil) + tarExtractor.EXPECT().Cleanup(gomock.Any()) + logger.EXPECT().Warnf("Failed to extract build context in temp folder. Dir: %s, Error: %s, Stderr: %s", + gomock.Any(), gomock.Any(), gomock.Any()) + // service should return error + _, err := service.Build(ctx, &buildOption, req.Body) + Expect(err.Error()).Should(Equal("failed to extract build context in temp folder")) + }) + It("should fail building image due build error from nerdctl", func() { + // set up the mock + mockCmd.EXPECT().Run().Return(nil) + errExpected := fmt.Errorf("nerdctl error") + ncBuilderSvc.EXPECT().Build(gomock.Any(), gomock.Any(), gomock.Any()).Return(errExpected) + tarExtractor.EXPECT().ExtractInTemp(gomock.Any(), gomock.Any()).Return(mockCmd, nil) + tarExtractor.EXPECT().Cleanup(gomock.Any()) + // service should return err + _, err := service.Build(ctx, &buildOption, req.Body) + Expect(err).Should(Equal(errExpected)) + }) + It("should successfully tag image after build", func() { + tag := "test-tag" + imageId := "test-image" + buildOption.Tag = []string{tag} + buildOption.Stdout = rr + + // set up mocks + ncBuilderSvc.EXPECT().Build(gomock.Any(), gomock.Any(), gomock.Any()) + expectPublishTagEvent(mockCtrl, tag).Return(&events.Event{ID: imageId}, nil) + tarExtractor.EXPECT().ExtractInTemp(gomock.Any(), gomock.Any()). + Return(mockCmd, nil) + tarExtractor.EXPECT().Cleanup(gomock.Any()) + mockCmd.EXPECT().Run().Return(nil) + + // service should not return any error + result, err := service.Build(ctx, &buildOption, req.Body) + Expect(err).Should(BeNil()) + Expect(result).Should(HaveLen(1)) + Expect(result[0].ID).Should(Equal(imageId)) + + // should stream output response with "Successfully built {id}" + data, err := io.ReadAll(rr.Body) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(data[:])).Should(ContainSubstring(fmt.Sprintf("Successfully built %s", imageId))) + }) + + }) +}) + +// expectPublishTagEvent creates a new mocked object for publishTagEvent function +// with expected input parameters. +func expectPublishTagEvent(ctrl *gomock.Controller, tag string) *mockPublishTagEvent { + m := &mockPublishTagEvent{ctrl: ctrl, expectedTag: tag} + ctrl.RecordCall(m, "PublishTagEvent", m.expectedTag) + publishTagEventFunc = func(_ *service, _ context.Context, tag string) (*events.Event, error) { + m.PublishTagEvent(tag) + return m.outputEvent, m.outputErr + } + return m +} +func (m *mockPublishTagEvent) PublishTagEvent(tag string) { + m.ctrl.Call(m, "PublishTagEvent", tag) +} +func (m *mockPublishTagEvent) Return(event *events.Event, err error) { + m.outputEvent = event + m.outputErr = err +} + +type mockPublishTagEvent struct { + expectedTag string + outputEvent *events.Event + outputErr error + ctrl *gomock.Controller +} diff --git a/pkg/service/builder/builder.go b/pkg/service/builder/builder.go new file mode 100644 index 00000000..2e8ddc73 --- /dev/null +++ b/pkg/service/builder/builder.go @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package builder consists of definition of service structures and methods related to build APIs +package builder + +import ( + "github.com/runfinch/finch-daemon/pkg/api/handlers/builder" + "github.com/runfinch/finch-daemon/pkg/archive" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +type NerdctlService interface { + backend.NerdctlBuilderSvc + backend.NerdctlImageSvc +} + +type service struct { + client backend.ContainerdClient + nctlBuilderSvc NerdctlService + logger flog.Logger + tarExtractor archive.TarExtractor +} + +// NewService creates a service struct for build APIs +func NewService( + client backend.ContainerdClient, + ncBuilderSvc NerdctlService, + logger flog.Logger, + tarExtractor archive.TarExtractor) builder.Service { + return &service{ + client: client, + nctlBuilderSvc: ncBuilderSvc, + logger: logger, + tarExtractor: tarExtractor, + } +} diff --git a/pkg/service/builder/builder_test.go b/pkg/service/builder/builder_test.go new file mode 100644 index 00000000..f00f25e9 --- /dev/null +++ b/pkg/service/builder/builder_test.go @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package builder + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" +) + +type mockNerdctlService struct { + *mocks_backend.MockNerdctlBuilderSvc + *mocks_backend.MockNerdctlImageSvc +} + +// TestContainerHandler function is the entry point of container service package's unit test using ginkgo +func TestContainerService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Build APIs Service") +} diff --git a/pkg/service/container/attach.go b/pkg/service/container/attach.go new file mode 100644 index 00000000..8416f337 --- /dev/null +++ b/pkg/service/container/attach.go @@ -0,0 +1,195 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "github.com/containerd/containerd" + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/api/types/cri" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/nerdctl/pkg/labels/k8slabels" + "github.com/containerd/nerdctl/pkg/logging" + "github.com/moby/moby/pkg/stdcopy" + + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// Attach attaches the stdout and stderr to the container using nerdctl logs +// +// TODO: Investigate fully implementing attach. See David's previous MR: +// https://github.com/containerd/nerdctl/pull/2108 +func (s *service) Attach(ctx context.Context, cid string, opts *types.AttachOptions) error { + // fetch container + con, err := s.getContainer(ctx, cid) + if err != nil { + return err + } + s.logger.Debugf("attaching container: %s", con.ID()) + + // set up io streams + outStream, errStream, stopChannel, printSuccessResp, err := opts.GetStreams() + if err != nil { + return err + } + + // if the caller wants neither to stream nor to view logs, return nothing + if !opts.Stream && !opts.Logs { + printSuccessResp() + return nil + } + + if opts.MuxStreams { + errStream = stdcopy.NewStdWriter(errStream, stdcopy.Stderr) + outStream = stdcopy.NewStdWriter(outStream, stdcopy.Stdout) + } + // TODO: implement stdin for a full attach implementation + var ( + //stdin io.Reader + stdout, stderr io.Writer + ) + if opts.UseStdin { + //stdin = inStream + } + if opts.UseStdout { + stdout = outStream + } + if opts.UseStderr { + stderr = errStream + } + + // Logs option determine if we are viewing the full logs (tail = 0) or just the current output + // with since = 0s. There is no way to use tail = 0 on nerdctl to return no logs + since := "0s" + if opts.Logs { + since = "" + } + + // assemble log options and call attachLogs (based off of nerdctl's container.Logs) + logOpts := ncTypes.ContainerLogsOptions{ + Stdout: stdout, + Stderr: stderr, + GOptions: ncTypes.GlobalCommandOptions{}, + Follow: opts.Stream, + Timestamps: false, + Tail: 0, + Since: since, + Until: "", + } + err = s.attachLogs(ctx, con, logOpts, stopChannel, printSuccessResp) + if err != nil { + s.logger.Debugf("failed to attach to the container: %s", cid) + return err + } + return nil +} + +// attachLogs sets up the logs and channels to be attached. Adapted from +// github.com/containerd/nerdctl/pkg/cmd/container.Logs to pass a stop channel +// and a success response message +func (s *service) attachLogs( + ctx context.Context, + con containerd.Container, + options ncTypes.ContainerLogsOptions, + stopChannel chan os.Signal, + printSuccessResp func(), +) error { + + dataStore, err := s.nctlContainerSvc.GetDataStore() + if err != nil { + return err + } + + l, err := con.Labels(ctx) + if err != nil { + return err + } + + logPath, err := getLogPath(ctx, con) + if err != nil { + return err + } + + status := s.client.GetContainerStatus(ctx, con) + if status != containerd.Running { + options.Follow = false + if status == containerd.Stopped { + // NOTE: This is a temporary workaround to fix the logger issue where strings without newline are not logged: + // https://github.com/containerd/nerdctl/issues/2313 + // TODO: Remove this logic when the issue is fixed in nerdctl + // delete old task to shutdown the logger and print buffered data + task, err := con.Task(ctx, nil) + if err == nil { + task.Delete(ctx) + time.Sleep(100 * time.Millisecond) + } + } + } + + if options.Follow { + task, waitCh, err := s.client.GetContainerTaskWait(ctx, nil, con) + if err != nil { + return fmt.Errorf("failed to get wait channel for task %#v: %s", task, err) + } + + // setup goroutine to send stop event if container task finishes: + go func() { + <-waitCh + s.logger.Debugf("container task has finished, sending kill signal to log viewer") + + // NOTE: This is a temporary workaround to fix the logger issue where strings without newline are not logged: + // https://github.com/containerd/nerdctl/issues/2313 + // TODO: Remove this logic when the issue is fixed in nerdctl + // delete finished task to shutdown the logger and print buffered data + task.Delete(ctx) + time.Sleep(100 * time.Millisecond) + + stopChannel <- os.Interrupt + }() + } + + logViewOpts := logging.LogViewOptions{ + ContainerID: con.ID(), + Namespace: l[labels.Namespace], + DatastoreRootPath: dataStore, + LogPath: logPath, + Follow: options.Follow, + Timestamps: options.Timestamps, + Tail: options.Tail, + Since: options.Since, + Until: options.Until, + } + logViewer, err := s.nctlContainerSvc.LoggingInitContainerLogViewer(l, logViewOpts, stopChannel, options.GOptions.Experimental) + if err != nil { + return err + } + + // Print success response to the connection, then return logs + printSuccessResp() + return s.nctlContainerSvc.LoggingPrintLogsTo(options.Stdout, options.Stderr, logViewer) +} + +// getLogPath gets the log path for the container to be attached. Original from +// github.com/containerd/nerdctl/pkg/cmd/container.getLogPath +func getLogPath(ctx context.Context, container containerd.Container) (string, error) { + extensions, err := container.Extensions(ctx) + if err != nil { + return "", fmt.Errorf("get extensions for container %s,failed: %#v", container.ID(), err) + } + metaData := extensions[k8slabels.ContainerMetadataExtension] + var meta cri.ContainerMetadata + if metaData != nil { + err = meta.UnmarshalJSON(metaData.GetValue()) + if err != nil { + return "", fmt.Errorf("unmarshal extensions for container %s,failed: %#v", container.ID(), err) + } + } + + return meta.LogPath, nil +} diff --git a/pkg/service/container/attach_test.go b/pkg/service/container/attach_test.go new file mode 100644 index 00000000..9b269d81 --- /dev/null +++ b/pkg/service/container/attach_test.go @@ -0,0 +1,262 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "github.com/containerd/containerd" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/nerdctl/pkg/labels/k8slabels" + "github.com/containerd/typeurl/v2" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + attachTypes "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Attach API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + tarExtractor *mocks_archive.MockTarExtractor + service container.Service + + mockWriter *bytes.Buffer + stopChannel chan os.Signal + setupStreams func() (io.Writer, io.Writer, chan os.Signal, func(), error) + cid string + ) + BeforeEach(func() { + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + + service = NewService(cdClient, mockNerdctlService{ncClient, nil}, logger, nil, nil, tarExtractor) + + mockWriter = new(bytes.Buffer) + stopChannel = make(chan os.Signal, 1) + signal.Notify(stopChannel, syscall.SIGTERM, syscall.SIGINT) + setupStreams = func() (io.Writer, io.Writer, chan os.Signal, func(), error) { + return mockWriter, mockWriter, stopChannel, func() {}, nil + } + cid = "test-container" + }) + Context("service", func() { + It("should return early with no error if stream & logs are false", func() { + // set up mocks + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + con.EXPECT().ID().Return(cid) + + opts := attachTypes.AttachOptions{ + GetStreams: setupStreams, + UseStdin: false, + UseStdout: false, + UseStderr: false, + MuxStreams: true, + Logs: false, + Stream: false, + } + err := service.Attach(ctx, cid, &opts) + Expect(err).Should(BeNil()) + Expect(mockWriter.String()).Should(Equal("")) + }) + It("should return an error if opts.GetStreams returns an error", func() { + // set up expected mocks, errors and the setupstreams to return an error + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + con.EXPECT().ID().Return(cid) + expErr := fmt.Errorf("error") + setupStreams = func() (io.Writer, io.Writer, chan os.Signal, func(), error) { + return nil, nil, nil, nil, expErr + } + // set up options + opts := attachTypes.AttachOptions{ + GetStreams: setupStreams, + UseStdin: true, + UseStdout: true, + UseStderr: true, + MuxStreams: false, + Logs: true, + Stream: true, + } + + // run function and assertions + err := service.Attach(ctx, cid, &opts) + Expect(err).Should(Equal(expErr)) + }) + It("should return an error if the datastore cannot be found", func() { + // set up mocks and expected errors + expErr := "error data store not found" + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().GetDataStore().Return("", fmt.Errorf(expErr)) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + // set up options + opts := attachTypes.AttachOptions{ + GetStreams: setupStreams, + UseStdin: true, + UseStdout: true, + UseStderr: true, + MuxStreams: true, + Logs: true, + Stream: true, + } + + // run function and assertions + err := service.Attach(ctx, cid, &opts) + Expect(err.Error()).Should(ContainSubstring(expErr)) + }) + It("should return a not found error if a container can't be found", func() { + // set up mocks + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + + // set up options + opts := attachTypes.AttachOptions{ + GetStreams: setupStreams, + UseStdin: true, + UseStdout: true, + UseStderr: true, + MuxStreams: true, + Logs: true, + Stream: true, + } + + // run function and assertions + err := service.Attach(ctx, cid, &opts) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should successfully attach to a container with logs=1, stream=0", func() { + // set up mocks + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().GetDataStore().Return("", nil) + con.EXPECT().Labels(gomock.Any()).Return(map[string]string{labels.Namespace: "test"}, nil) + // construct typeURL.Any object as getLogPath calls it to unmarshall and get a value + type testJSONObj struct{ LogPath string } + testJSON := &testJSONObj{LogPath: ""} + testAny, _ := typeurl.MarshalAny(testJSON) + // continue setting up mocks + con.EXPECT().Extensions(gomock.Any()).Return(map[string]typeurl.Any{ + k8slabels.ContainerMetadataExtension: testAny}, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Running) + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().LoggingInitContainerLogViewer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + ncClient.EXPECT().LoggingPrintLogsTo(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + // set up options + opts := attachTypes.AttachOptions{ + GetStreams: setupStreams, + UseStdin: true, + UseStdout: true, + UseStderr: true, + MuxStreams: true, + Logs: true, + Stream: false, + } + + // run function and assertions + err := service.Attach(ctx, cid, &opts) + Expect(err).Should(BeNil()) + }) + It("should successfully attach to a container with logs=1, stream=1, and not follow when stopped", func() { + // set up mocks + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().GetDataStore().Return("", nil) + con.EXPECT().Labels(gomock.Any()).Return(map[string]string{labels.Namespace: "test"}, nil) + // construct typeURL.Any object as getLogPath calls it to unmarshall and get a value + type testJSONObj struct{ LogPath string } + testJSON := &testJSONObj{LogPath: ""} + testAny, _ := typeurl.MarshalAny(testJSON) + // continue setting up mocks + con.EXPECT().Extensions(gomock.Any()).Return(map[string]typeurl.Any{ + k8slabels.ContainerMetadataExtension: testAny}, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Stopped) + con.EXPECT().Task(gomock.Any(), nil).Return(nil, fmt.Errorf("error")) + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().LoggingInitContainerLogViewer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + ncClient.EXPECT().LoggingPrintLogsTo(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + // set up options + opts := attachTypes.AttachOptions{ + GetStreams: setupStreams, + UseStdin: true, + UseStdout: true, + UseStderr: true, + MuxStreams: true, + Logs: true, + Stream: true, + } + + // run function and assertions + err := service.Attach(ctx, cid, &opts) + Expect(err).Should(BeNil()) + }) + It("should return an error with logs=1, stream=1 and a running container when failed to get wait channel", func() { + // set up expected error and mocks + expErr := "error task wait channel" + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().GetDataStore().Return("", nil) + con.EXPECT().Labels(gomock.Any()).Return(map[string]string{labels.Namespace: "test"}, nil) + // construct typeURL.Any object as getLogPath calls it to unmarshall and get a value + type testJSONObj struct{ LogPath string } + testJSON := &testJSONObj{LogPath: ""} + testAny, _ := typeurl.MarshalAny(testJSON) + // continue setting up mocks + con.EXPECT().Extensions(gomock.Any()).Return(map[string]typeurl.Any{ + k8slabels.ContainerMetadataExtension: testAny}, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Running) + cdClient.EXPECT().GetContainerTaskWait(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, fmt.Errorf(expErr)) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + + // set up options + opts := attachTypes.AttachOptions{ + GetStreams: setupStreams, + UseStdin: true, + UseStdout: true, + UseStderr: true, + MuxStreams: true, + Logs: true, + Stream: true, + } + + // run function and assertions + err := service.Attach(ctx, cid, &opts) + Expect(err.Error()).Should(ContainSubstring(expErr)) + }) + }) +}) diff --git a/pkg/service/container/container.go b/pkg/service/container/container.go new file mode 100644 index 00000000..ade9a169 --- /dev/null +++ b/pkg/service/container/container.go @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package container comprises functions and structures related to container APIs +package container + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/archive" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/flog" + "github.com/runfinch/finch-daemon/pkg/statsutil" + "github.com/spf13/afero" +) + +type NerdctlService interface { + backend.NerdctlContainerSvc + backend.NerdctlNetworkSvc +} + +type service struct { + client backend.ContainerdClient + nctlContainerSvc NerdctlService + logger flog.Logger + fs afero.Fs + tarCreator archive.TarCreator + tarExtractor archive.TarExtractor + stats statsutil.StatsUtil +} + +// NewService creates a new service to operate on containers +func NewService( + client backend.ContainerdClient, + nerdctlContainerSvc NerdctlService, + logger flog.Logger, + fs afero.Fs, + tarCreator archive.TarCreator, + tarExtractor archive.TarExtractor, +) container.Service { + return &service{ + client: client, + nctlContainerSvc: nerdctlContainerSvc, + logger: logger, + fs: fs, + tarCreator: tarCreator, + tarExtractor: tarExtractor, + stats: statsutil.NewStatsUtil(), + } +} + +// getContainer returns a containerd container from container id +func (s *service) getContainer(ctx context.Context, cid string) (containerd.Container, error) { + searchResult, err := s.client.SearchContainer(ctx, cid) + if err != nil { + s.logger.Errorf("failed to search container: %s. error: %s", cid, err.Error()) + return nil, err + } + matchCount := len(searchResult) + + // if container not found then return NotFound error. + if matchCount == 0 { + s.logger.Debugf("no such container: %s", cid) + return nil, errdefs.NewNotFound(fmt.Errorf("no such container: %s", cid)) + } + // if more than one container found with the provided id return error. + if matchCount > 1 { + s.logger.Debugf("multiple IDs found with provided prefix: %s, total containers found: %d", cid, matchCount) + return nil, fmt.Errorf("multiple IDs found with provided prefix: %s", cid) + } + + return searchResult[0], nil +} diff --git a/pkg/service/container/container_test.go b/pkg/service/container/container_test.go new file mode 100644 index 00000000..77571afe --- /dev/null +++ b/pkg/service/container/container_test.go @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + "testing" + + "github.com/containerd/containerd" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +type mockNerdctlService struct { + *mocks_backend.MockNerdctlContainerSvc + *mocks_backend.MockNerdctlNetworkSvc +} + +// TestContainerService is the entry point of container service package's unit tests using ginkgo +func TestContainerService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Container APIs Service") +} + +var _ = Describe("Container API service common ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + con *mocks_container.MockContainer + cid string + s service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + cid = "123" + con = mocks_container.NewMockContainer(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + s = service{ + client: cdClient, + nctlContainerSvc: mockNerdctlService{ncClient, nil}, + logger: logger, + } + }) + Context("getContainer", func() { + It("should return the container object if it was found", func() { + // search method returns one container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + + result, err := s.getContainer(ctx, cid) + Expect(result).Should(Equal(con)) + Expect(err).Should(BeNil()) + }) + It("should return an error if search container method fails", func() { + // search method returns no container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + nil, errors.New("search container error")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + + result, err := s.getContainer(ctx, cid) + Expect(result).Should(BeNil()) + Expect(err).Should(Not(BeNil())) + }) + It("should return NotFound error if no container was found", func() { + // search method returns no container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + result, err := s.getContainer(ctx, cid) + Expect(result).Should(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return an error if multiple containers were found", func() { + // search method returns two containers + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con, con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + result, err := s.getContainer(ctx, cid) + Expect(result).Should(BeNil()) + Expect(err).Should(Not(BeNil())) + }) + }) +}) diff --git a/pkg/service/container/create.go b/pkg/service/container/create.go new file mode 100644 index 00000000..2508b73f --- /dev/null +++ b/pkg/service/container/create.go @@ -0,0 +1,145 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/cio" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/clientutil" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/nerdctl/pkg/logging" + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/sirupsen/logrus" +) + +func (s *service) Create(ctx context.Context, image string, cmd []string, createOpt types.ContainerCreateOptions, netOpt types.NetworkOptions) (cid string, err error) { + // Set path to nerdctl binary required for for OCI hooks and logging + if createOpt.NerdctlCmd == "" { + ncExe, err := s.nctlContainerSvc.GetNerdctlExe() + if err != nil { + return "", fmt.Errorf("failed to find nerdctl binary: %s", err) + } + createOpt.NerdctlCmd = ncExe + createOpt.NerdctlArgs = []string{} + } + + // translate network IDs to names because nerdctl currently does not recognize networks by their IDs during create. + // TODO: remove this when the issue is fixed upstream. + if err := s.translateNetworkIds(&netOpt); err != nil { + return "", err + } + + netManager, err := s.nctlContainerSvc.NewNetworkingOptionsManager(netOpt) + if err != nil { + logrus.Debugf("error creating network manager for the given network options: %s", err) + return "", err + } + + args := []string{image} + args = append(args, cmd...) + cont, gc, err := s.nctlContainerSvc.CreateContainer(ctx, args, netManager, createOpt) + if err != nil { + if gc != nil { + gc() + } + logrus.Debugf("failed to create container: %s", err) + + // translate error definitions from containerd + switch { + case cerrdefs.IsNotFound(err): + return "", errdefs.NewNotFound(err) + case cerrdefs.IsInvalidArgument(err): + return "", errdefs.NewInvalidFormat(err) + case cerrdefs.IsAlreadyExists(err): + return "", errdefs.NewConflict(err) + default: + return "", err + } + } + + // NOTE: this is a temporary workaround to fix logging issue described in https://github.com/containerd/nerdctl/issues/2264. + // The refactored create method in nerdctl uses self exe (finch-daemon) binary for logging instead of nerdctl binary path. + // The following workaround resets this logging binary in the OCI spec. + // TODO: remove this workaround when the issue is resolved upstream. + resetLogURI(ctx, createOpt, cont) + + return cont.ID(), nil +} + +// translateNetworkIds translates network IDs to corresponding network names in network options. +func (s *service) translateNetworkIds(netOpt *types.NetworkOptions) error { + for i, netId := range netOpt.NetworkSlice { + if netId == "host" || netId == "none" || netId == "bridge" { + continue + } + + netList, err := s.nctlContainerSvc.FilterNetworks(func(networkConfig *netutil.NetworkConfig) bool { + return networkConfig.Name == netId || *networkConfig.NerdctlID == netId + }) + if err != nil { + return err + } + if len(netList) == 0 { + return errdefs.NewNotFound(fmt.Errorf("network not found: %s", netId)) + } else if len(netList) > 1 { + return fmt.Errorf("multiple networks found for id: %s", netId) + } + netOpt.NetworkSlice[i] = netList[0].Name + } + + return nil +} + +func resetLogURI(ctx context.Context, createOpt types.ContainerCreateOptions, cont containerd.Container) error { + // get data store directory for logging + dataStore, err := clientutil.DataStore(createOpt.GOptions.DataRoot, createOpt.GOptions.Address) + if err != nil { + logrus.Errorf("failed to get nerdctl data store: %s", err) + return err + } + + // create a log URI using nerdctl binary path + args := map[string]string{ + logging.MagicArgv1: dataStore, + } + logURI, err := cio.LogURIGenerator("binary", createOpt.NerdctlCmd, args) + if err != nil { + logrus.Errorf("failed to generate a log URI: %s", err) + return err + } + + // reset container label with new log URI + opts, err := cont.Labels(ctx) + if err != nil { + logrus.Errorf("failed to get container labels: %s", err) + return err + } + opts[labels.LogURI] = logURI.String() + + // reset OCI spec with new log URI + spec, err := cont.Spec(ctx) + if err != nil { + logrus.Errorf("failed to get container OCI spec: %s", err) + return err + } + spec.Annotations[labels.LogURI] = logURI.String() + + // update container + err = cont.Update(ctx, + containerd.UpdateContainerOpts(containerd.WithContainerLabels(opts)), + containerd.UpdateContainerOpts(containerd.WithSpec(spec)), + ) + if err != nil { + logrus.Errorf("failed to update container: %s", err) + return err + } + + return nil +} diff --git a/pkg/service/container/create_test.go b/pkg/service/container/create_test.go new file mode 100644 index 00000000..a5bf81f5 --- /dev/null +++ b/pkg/service/container/create_test.go @@ -0,0 +1,289 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/nerdctl/pkg/api/types" + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containernetworking/cni/libcni" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to container create API +var _ = Describe("Container Create API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncContainerSvc *mocks_backend.MockNerdctlContainerSvc + ncNetworkSvc *mocks_backend.MockNerdctlNetworkSvc + ncExe string + image string + cmd []string + createOpt types.ContainerCreateOptions + createOptExp types.ContainerCreateOptions + netOpt types.NetworkOptions + netManager *mocks_container.MockNetworkOptionsManager + con *mocks_container.MockContainer + cid string + svc *service + tarExtractor *mocks_archive.MockTarExtractor + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncContainerSvc = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + ncNetworkSvc = mocks_backend.NewMockNerdctlNetworkSvc(mockCtrl) + ncExe = "/usr/local/bin/nerdctl" + image = "test-image" + cmd = []string{"echo", "hello world"} + createOpt = types.ContainerCreateOptions{} + createOptExp = types.ContainerCreateOptions{NerdctlCmd: ncExe, NerdctlArgs: []string{}} + netOpt = types.NetworkOptions{} + netManager = mocks_container.NewMockNetworkOptionsManager(mockCtrl) + cid = "test-container-id" + con = mocks_container.NewMockContainer(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + + //service = NewService(cdClient, mockNerdctlService{ncContainerSvc, ncNetworkSvc}, logger, nil, nil, tarExtractor) + svc = &service{ + client: cdClient, + nctlContainerSvc: mockNerdctlService{ncContainerSvc, ncNetworkSvc}, + logger: logger, + tarExtractor: tarExtractor, + } + }) + Context("service", func() { + It("should successfully create a container", func() { + ncContainerSvc.EXPECT().GetNerdctlExe().Return(ncExe, nil) + ncContainerSvc.EXPECT().NewNetworkingOptionsManager(netOpt).Return(netManager, nil) + + // container create arguments + args := []string{image} + args = append(args, cmd...) + ncContainerSvc.EXPECT().CreateContainer(ctx, args, netManager, createOptExp).Return( + con, nil, nil) + + // service should not return any error and the returned cid should match expected + cidResult, err := svc.Create(ctx, image, cmd, createOpt, netOpt) + Expect(cidResult).Should(Equal(cid)) + Expect(err).Should(BeNil()) + }) + It("should return internal error for network options create failure", func() { + mockErr := errors.New("error while creating networking options") + ncContainerSvc.EXPECT().GetNerdctlExe().Return(ncExe, nil) + ncContainerSvc.EXPECT().NewNetworkingOptionsManager(gomock.Any()).Return(nil, mockErr) + + // service should return with an error + cidResult, err := svc.Create(ctx, image, nil, createOpt, netOpt) + Expect(cidResult).Should(BeEmpty()) + Expect(err.Error()).Should(Equal(mockErr.Error())) + }) + It("should return internal error for container create failure", func() { + ncContainerSvc.EXPECT().GetNerdctlExe().Return(ncExe, nil) + ncContainerSvc.EXPECT().NewNetworkingOptionsManager(netOpt).Return(netManager, nil) + + // container create arguments + args := []string{image} + args = append(args, cmd...) + + mockErr := errors.New("error while creating a container") + ncContainerSvc.EXPECT().CreateContainer(ctx, args, netManager, createOptExp).Return( + nil, nil, mockErr) + + // service should return with an error + cidResult, err := svc.Create(ctx, image, cmd, createOpt, netOpt) + Expect(cidResult).Should(BeEmpty()) + Expect(err.Error()).Should(Equal(mockErr.Error())) + }) + It("should call garbage collector upon container create failure", func() { + ncContainerSvc.EXPECT().GetNerdctlExe().Return(ncExe, nil) + ncContainerSvc.EXPECT().NewNetworkingOptionsManager(netOpt).Return(netManager, nil) + + // container create arguments + args := []string{image} + args = append(args, cmd...) + + // define mock garbage cleanup method + gcFlag := false + mockGc := func() { + gcFlag = true + } + + mockErr := errors.New("error while creating a container") + ncContainerSvc.EXPECT().CreateContainer(ctx, args, netManager, createOptExp).Return( + nil, mockGc, mockErr) + + // service should call garbage collector and return with an error + cidResult, err := svc.Create(ctx, image, cmd, createOpt, netOpt) + Expect(cidResult).Should(BeEmpty()) + Expect(gcFlag).Should(BeTrue()) + Expect(err.Error()).Should(Equal(mockErr.Error())) + }) + It("should return not-found error if image was not found", func() { + ncContainerSvc.EXPECT().GetNerdctlExe().Return(ncExe, nil) + ncContainerSvc.EXPECT().NewNetworkingOptionsManager(netOpt).Return(netManager, nil) + + // container create arguments + args := []string{image} + args = append(args, cmd...) + + ncContainerSvc.EXPECT().CreateContainer(ctx, args, netManager, createOptExp).Return( + nil, nil, cerrdefs.ErrNotFound) + + // service should return with an error + cidResult, err := svc.Create(ctx, image, cmd, createOpt, netOpt) + Expect(cidResult).Should(BeEmpty()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return invalid-format error if the inputs are invalid", func() { + ncContainerSvc.EXPECT().GetNerdctlExe().Return(ncExe, nil) + ncContainerSvc.EXPECT().NewNetworkingOptionsManager(netOpt).Return(netManager, nil) + + // container create arguments + args := []string{image} + args = append(args, cmd...) + + ncContainerSvc.EXPECT().CreateContainer(ctx, args, netManager, createOptExp).Return( + nil, nil, cerrdefs.ErrInvalidArgument) + + // service should return with an error + cidResult, err := svc.Create(ctx, image, cmd, createOpt, netOpt) + Expect(cidResult).Should(BeEmpty()) + Expect(errdefs.IsInvalidFormat(err)).Should(BeTrue()) + }) + It("should return conflict error if container name already exists", func() { + ncContainerSvc.EXPECT().GetNerdctlExe().Return(ncExe, nil) + ncContainerSvc.EXPECT().NewNetworkingOptionsManager(netOpt).Return(netManager, nil) + + // container create arguments + args := []string{image} + args = append(args, cmd...) + + ncContainerSvc.EXPECT().CreateContainer(ctx, args, netManager, createOptExp).Return( + nil, nil, cerrdefs.ErrAlreadyExists) + + // service should return with an error + cidResult, err := svc.Create(ctx, image, cmd, createOpt, netOpt) + Expect(cidResult).Should(BeEmpty()) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + }) + It("should return an error if nerdctl binary was not found", func() { + mockErr := errors.New("could not find nerdctl binary") + ncContainerSvc.EXPECT().GetNerdctlExe().Return("", mockErr) + + // service should return with an error + cidResult, err := svc.Create(ctx, image, nil, createOpt, netOpt) + Expect(cidResult).Should(BeEmpty()) + Expect(err.Error()).Should(ContainSubstring(mockErr.Error())) + }) + }) + Context("translate network IDs", func() { + It("should translate network ids to network names for specified networks", func() { + // network options + netIds := []string{"network-id1", "network-id2"} + netNames := []string{"network1", "network2"} + netOpt := types.NetworkOptions{ + NetworkSlice: netIds, + } + + // FilterNetworks returns a single network config for each network id + ncNetworkSvc.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{ + {NetworkConfigList: &libcni.NetworkConfigList{Name: netNames[0]}}, + }, nil) + ncNetworkSvc.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{ + {NetworkConfigList: &libcni.NetworkConfigList{Name: netNames[1]}}, + }, nil) + + // network ids should be translated to corresponding names without error + err := svc.translateNetworkIds(&netOpt) + Expect(err).Should(BeNil()) + Expect(netOpt.NetworkSlice).Should(Equal(netNames)) + }) + It("should ignore host, none, and bridge networks for network id translation", func() { + // network options + netIds := []string{"network-id1", "bridge", "host", "network-id2", "none"} + netNames := []string{"network1", "bridge", "host", "network2", "none"} + netOpt := types.NetworkOptions{ + NetworkSlice: netIds, + } + + // FilterNetworks returns a single network config for each network id + // but should not be called for bridge, host, and none networks + ncNetworkSvc.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{ + {NetworkConfigList: &libcni.NetworkConfigList{Name: netNames[0]}}, + }, nil) + ncNetworkSvc.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{ + {NetworkConfigList: &libcni.NetworkConfigList{Name: netNames[3]}}, + }, nil) + + // network ids should be translated to corresponding names without error + err := svc.translateNetworkIds(&netOpt) + Expect(err).Should(BeNil()) + Expect(netOpt.NetworkSlice).Should(Equal(netNames)) + }) + It("should return an error if filter networks failed", func() { + mockErr := errors.New("filter networks failure") + + // network options + netOpt := types.NetworkOptions{ + NetworkSlice: []string{"test-network-id"}, + } + + // FilterNetworks returns an error + ncNetworkSvc.EXPECT().FilterNetworks(gomock.Any()).Return(nil, mockErr) + + // function should propogate the error from FilterNetworks + err := svc.translateNetworkIds(&netOpt) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(ContainSubstring(mockErr.Error())) + }) + It("should return an error if network was not found", func() { + // network options + netOpt := types.NetworkOptions{ + NetworkSlice: []string{"test-network-id"}, + } + + // FilterNetworks returns 0 networks + ncNetworkSvc.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + + // function should return a not found error + err := svc.translateNetworkIds(&netOpt) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return an error if multiple networks are found for the same id", func() { + // network options + netOpt := types.NetworkOptions{ + NetworkSlice: []string{"test-network-id"}, + } + + // FilterNetworks returns 2 networks + ncNetworkSvc.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{ + {NetworkConfigList: &libcni.NetworkConfigList{Name: "network1"}}, + {NetworkConfigList: &libcni.NetworkConfigList{Name: "network2"}}, + }, nil) + + // function should return an error + err := svc.translateNetworkIds(&netOpt) + Expect(err).ShouldNot(BeNil()) + }) + }) +}) diff --git a/pkg/service/container/exec.go b/pkg/service/container/exec.go new file mode 100644 index 00000000..734580c2 --- /dev/null +++ b/pkg/service/container/exec.go @@ -0,0 +1,169 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/cio" + "github.com/containerd/containerd/containers" + "github.com/containerd/containerd/defaults" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/oci" + "github.com/containerd/nerdctl/pkg/flagutil" + "github.com/containerd/nerdctl/pkg/idgen" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (s *service) ExecCreate(ctx context.Context, cid string, config types.ExecConfig) (string, error) { + con, err := s.getContainer(ctx, cid) + if err != nil { + if cerrdefs.IsNotFound(err) { + return "", errdefs.NewNotFound(err) + } + return "", err + } + + pspec, err := s.generateExecProcessSpec(ctx, con, config) + if err != nil { + return "", err + } + + task, err := con.Task(ctx, nil) + if err != nil { + if cerrdefs.IsNotFound(err) { + return "", errdefs.NewConflict(fmt.Errorf("container %s is not running", cid)) + } + return "", err + } + + status, err := task.Status(ctx) + if err != nil { + if cerrdefs.IsNotFound(err) { + return "", errdefs.NewConflict(fmt.Errorf("container %s is not running", cid)) + } + return "", err + } + if status.Status != containerd.Running { + return "", errdefs.NewConflict(fmt.Errorf("container %s is not running", cid)) + } + + execID := "exec-" + idgen.GenerateID() + + ioCreator := func(id string) (cio.IO, error) { + fifos, err := s.client.NewFIFOSetInDir(defaults.DefaultFIFODir, id, config.Tty) + if err != nil { + return nil, err + } + + if !config.AttachStdin { + fifos.Stdin = "" + } + if !config.AttachStdout { + fifos.Stdout = "" + } + if !config.AttachStderr { + fifos.Stderr = "" + } + + directIO, err := s.client.NewDirectCIO(ctx, fifos) + if err != nil { + return nil, err + } + + return directIO, nil + } + + // ignore the returned process because we will load it later in exec_start + _, err = task.Exec(ctx, execID, pspec, ioCreator) + if err != nil { + return "", err + } + + // the task and process keep track of most of the state we care about, but we first need the container to access its task & process by execID. + return fmt.Sprintf("%s/%s", cid, execID), nil +} + +func (s *service) generateExecProcessSpec(ctx context.Context, container containerd.Container, config types.ExecConfig) (*specs.Process, error) { + spec, err := container.Spec(ctx) + if err != nil { + return nil, err + } + + userOpts, err := s.generateUserOpts(config.User) + if err != nil { + return nil, err + } + if userOpts != nil { + c, err := container.Info(ctx) + if err != nil { + return nil, err + } + for _, opt := range userOpts { + if err := opt(ctx, s.client.GetClient(), &c, spec); err != nil { + return nil, err + } + } + } + + pspec := spec.Process + pspec.Terminal = config.Tty + if pspec.Terminal && config.ConsoleSize != nil { + pspec.ConsoleSize = &specs.Box{Height: config.ConsoleSize[0], Width: config.ConsoleSize[1]} + } + pspec.Args = config.Cmd + + if config.WorkingDir != "" { + pspec.Cwd = config.WorkingDir + } + envs := config.Env + pspec.Env = flagutil.ReplaceOrAppendEnvValues(pspec.Env, envs) + + if config.Privileged { + err = s.setExecCapabilities(pspec) + if err != nil { + return nil, err + } + } + + return pspec, nil +} + +func (s *service) generateUserOpts(user string) ([]oci.SpecOpts, error) { + var opts []oci.SpecOpts + if user != "" { + opts = append(opts, s.client.OCISpecWithUser(user), withResetAdditionalGIDs(), s.client.OCISpecWithAdditionalGIDs(user)) + } + return opts, nil +} + +func (s *service) setExecCapabilities(pspec *specs.Process) error { + if pspec.Capabilities == nil { + pspec.Capabilities = &specs.LinuxCapabilities{} + } + allCaps, err := s.client.GetCurrentCapabilities() + if err != nil { + return err + } + pspec.Capabilities.Bounding = allCaps + pspec.Capabilities.Permitted = pspec.Capabilities.Bounding + pspec.Capabilities.Inheritable = pspec.Capabilities.Bounding + pspec.Capabilities.Effective = pspec.Capabilities.Bounding + + // https://github.com/moby/moby/pull/36466/files + // > `docker exec --privileged` does not currently disable AppArmor + // > profiles. Privileged configuration of the container is inherited + return nil +} + +func withResetAdditionalGIDs() oci.SpecOpts { + return func(_ context.Context, _ oci.Client, _ *containers.Container, s *oci.Spec) error { + s.Process.User.AdditionalGids = nil + return nil + } +} diff --git a/pkg/service/container/exec_test.go b/pkg/service/container/exec_test.go new file mode 100644 index 00000000..1330e278 --- /dev/null +++ b/pkg/service/container/exec_test.go @@ -0,0 +1,521 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + "fmt" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/cio" + "github.com/containerd/containerd/containers" + "github.com/containerd/containerd/defaults" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/oci" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/spf13/afero" +) + +var _ = Describe("Container Exec API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + con *mocks_container.MockContainer + task *mocks_container.MockTask + proc *mocks_container.MockProcess + service container.Service + fs afero.Fs + tarCreator *mocks_archive.MockTarCreator + tarExtractor *mocks_archive.MockTarExtractor + execConfig types.ExecConfig + pspec *specs.Process + ) + BeforeEach(func() { + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + fs = afero.NewMemMapFs() + tarCreator = mocks_archive.NewMockTarCreator(mockCtrl) + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + task = mocks_container.NewMockTask(mockCtrl) + proc = mocks_container.NewMockProcess(mockCtrl) + service = NewService( + cdClient, + mockNerdctlService{ncClient, nil}, + logger, + fs, + tarCreator, + tarExtractor, + ) + execConfig = types.ExecConfig{ + User: "foo", + Privileged: true, + Tty: true, + ConsoleSize: &[2]uint{123, 321}, + AttachStdin: true, + AttachStderr: true, + AttachStdout: true, + Detach: false, + DetachKeys: "foo", + Env: []string{"foo=bar", "bar=baz"}, + WorkingDir: "path/to/dir", + Cmd: []string{"foo", "bar"}, + } + pspec = &specs.Process{ + Terminal: execConfig.Tty, + ConsoleSize: &specs.Box{Height: 123, Width: 321}, + User: specs.User{ + UID: 123, + GID: 123, + AdditionalGids: []uint32{1, 2, 3}, + }, + Args: execConfig.Cmd, + CommandLine: "", + Env: execConfig.Env, + Cwd: execConfig.WorkingDir, + Capabilities: &specs.LinuxCapabilities{ + Bounding: []string{"foo", "bar"}, + Permitted: []string{"foo", "bar"}, + Inheritable: []string{"foo", "bar"}, + Effective: []string{"foo", "bar"}, + }, + } + }) + Context("service", func() { + It("should not return any errors on success", func() { + var eid string + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: containerd.Running}, nil) + task.EXPECT().Exec(ctx, gomock.Any(), pspec, gomock.Any()).DoAndReturn( + func(ctx context.Context, execId string, pspec *specs.Process, ioCreate cio.Creator) (containerd.Process, error) { + eid = execId + fifos := &cio.FIFOSet{ + Config: cio.Config{ + Stdin: "stdin", + Stdout: "stdout", + Stderr: "stderr", + Terminal: execConfig.Tty, + }, + } + cdClient.EXPECT().NewFIFOSetInDir(defaults.DefaultFIFODir, execId, execConfig.Tty).Return(fifos, nil) + cdClient.EXPECT().NewDirectCIO(ctx, fifos).Return(&cio.DirectIO{}, nil) + + _, err := ioCreate(execId) + Expect(err).Should(BeNil()) + + return proc, nil + }) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).Should(BeNil()) + Expect(execId).Should(Equal(fmt.Sprintf("123/%s", eid))) + }) + It("should not create fifos for stdio when they aren't supposed to be attached", func() { + execConfig.AttachStdin = false + execConfig.AttachStderr = false + execConfig.AttachStdout = false + + var eid string + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: containerd.Running}, nil) + task.EXPECT().Exec(ctx, gomock.Any(), pspec, gomock.Any()).DoAndReturn( + func(ctx context.Context, execId string, pspec *specs.Process, ioCreate cio.Creator) (containerd.Process, error) { + eid = execId + fifos := &cio.FIFOSet{ + Config: cio.Config{ + Stdin: "stdin", + Stdout: "stdout", + Stderr: "stderr", + Terminal: execConfig.Tty, + }, + } + expectFifos := &cio.FIFOSet{ + Config: cio.Config{ + Stdin: "", + Stdout: "", + Stderr: "", + Terminal: execConfig.Tty, + }, + } + cdClient.EXPECT().NewFIFOSetInDir(defaults.DefaultFIFODir, execId, execConfig.Tty).Return(fifos, nil) + cdClient.EXPECT().NewDirectCIO(ctx, expectFifos).Return(&cio.DirectIO{}, nil) + + _, err := ioCreate(execId) + Expect(err).Should(BeNil()) + + return proc, nil + }) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).Should(BeNil()) + Expect(execId).Should(Equal(fmt.Sprintf("123/%s", eid))) + }) + It("should return a NotFound error if the container is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return(nil, cerrdefs.ErrNotFound) + logger.EXPECT().Errorf("failed to search container: %s. error: %s", "123", gomock.Any()) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through other errors from getContainer", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{}, nil) + logger.EXPECT().Debugf("no such container: %s", "123") + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + Expect(err.Error()).Should(Equal("no such container: 123")) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through errors from container.Spec", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(nil, errors.New("spec error")) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("spec error")) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through errors from container.Info", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, errors.New("info error")) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("info error")) + Expect(execId).Should(BeEmpty()) + }) + It("should throw an error if any oci.SpecOpts throw an error", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + return errors.New("withUser error") + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(1) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("withUser error")) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through errors from GetCurrentCapabilities", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return(nil, errors.New("getCaps error")) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("getCaps error")) + Expect(execId).Should(BeEmpty()) + }) + It("should return a Conflict error if the task is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(nil, cerrdefs.ErrNotFound) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + Expect(err.Error()).Should(Equal("container 123 is not running")) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through any other error from container.Task", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(nil, errors.New("task error")) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("task error")) + Expect(execId).Should(BeEmpty()) + }) + It("should return a Conflict error if the task status is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{}, cerrdefs.ErrNotFound) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + Expect(err.Error()).Should(Equal("container 123 is not running")) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through any other error from task.Status", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{}, errors.New("status error")) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("status error")) + Expect(execId).Should(BeEmpty()) + }) + It("should return a Conflict error if the status is not running", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: containerd.Stopped}, nil) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + Expect(err.Error()).Should(Equal("container 123 is not running")) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through any errors from task.Exec", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: containerd.Running}, nil) + task.EXPECT().Exec(ctx, gomock.Any(), pspec, gomock.Any()).Return(nil, errors.New("exec error")) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("exec error")) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through errors from NewFIFOSetInDir", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: containerd.Running}, nil) + task.EXPECT().Exec(ctx, gomock.Any(), pspec, gomock.Any()).DoAndReturn( + func(ctx context.Context, execId string, pspec *specs.Process, ioCreate cio.Creator) (containerd.Process, error) { + cdClient.EXPECT().NewFIFOSetInDir(defaults.DefaultFIFODir, execId, execConfig.Tty).Return(nil, errors.New("fifo error")) + + _, err := ioCreate(execId) + return nil, err + }) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("fifo error")) + Expect(execId).Should(BeEmpty()) + }) + It("should pass through errors from NewDirectCIO", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&oci.Spec{Process: &specs.Process{}}, nil) + cdClient.EXPECT().OCISpecWithUser(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.UID = 123 + spec.Process.User.GID = 123 + return nil + }) + cdClient.EXPECT().OCISpecWithAdditionalGIDs(execConfig.User).Return( + func(_ context.Context, _ oci.Client, _ *containers.Container, spec *oci.Spec) error { + spec.Process.User.AdditionalGids = []uint32{1, 2, 3} + return nil + }) + con.EXPECT().Info(ctx).Return(containers.Container{}, nil) + cdClient.EXPECT().GetClient().Return(&containerd.Client{}).Times(3) + cdClient.EXPECT().GetCurrentCapabilities().Return([]string{"foo", "bar"}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: containerd.Running}, nil) + task.EXPECT().Exec(ctx, gomock.Any(), pspec, gomock.Any()).DoAndReturn( + func(ctx context.Context, execId string, pspec *specs.Process, ioCreate cio.Creator) (containerd.Process, error) { + fifos := &cio.FIFOSet{ + Config: cio.Config{ + Stdin: "stdin", + Stdout: "stdout", + Stderr: "stderr", + Terminal: execConfig.Tty, + }, + } + cdClient.EXPECT().NewFIFOSetInDir(defaults.DefaultFIFODir, execId, execConfig.Tty).Return(fifos, nil) + cdClient.EXPECT().NewDirectCIO(ctx, fifos).Return(nil, errors.New("cio error")) + + _, err := ioCreate(execId) + return nil, err + }) + + execId, err := service.ExecCreate(ctx, "123", execConfig) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("cio error")) + Expect(execId).Should(BeEmpty()) + }) + }) +}) diff --git a/pkg/service/container/get_archive.go b/pkg/service/container/get_archive.go new file mode 100644 index 00000000..67235cad --- /dev/null +++ b/pkg/service/container/get_archive.go @@ -0,0 +1,122 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + "io" + "os" + "path" + + "github.com/containerd/containerd" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/spf13/afero" +) + +// GetPathToFilesInContainer locates files in a container. If the container is running, it will use the running container's +// /proc filesystem. If the container is not running, it will use the snapshotter to mount its filesystem to a tempdir. +// In the latter case, some cleanup is required, in which case it will return a func() that will handle the cleanup. +func (s *service) GetPathToFilesInContainer(ctx context.Context, cid string, srcPath string) (filePath string, cleanup func(), err error) { + con, err := s.getContainer(ctx, cid) + if err != nil { + s.logger.Errorf("Error getting container: %s", err) + return + } + + var root string + + task, err := con.Task(ctx, nil) + if err != nil { + // if the task is simply not found, we should try to mount the snapshot. any other type of error from Task() is fatal here. + if !cerrdefs.IsNotFound(err) { + s.logger.Errorf("Error getting task from container: %s", err) + return + } + + root, cleanup, err = s.mountSnapshotForContainer(ctx, con) + if err != nil { + s.logger.Errorf("Could not mount snapshot: %s", err) + return + } + } else { + var status containerd.Status + status, err = task.Status(ctx) + if err != nil { + s.logger.Errorf("Could not get task status: %s", err) + return + } + if status.Status == containerd.Running { + pid := task.Pid() + // path to the container's root filesystem. reference: + // https://github.com/containerd/nerdctl/blob/774b6e9ab69fadbcffb60297791db3f036231abf/pkg/containerutil/cp_linux.go#L44 + root = fmt.Sprintf("/proc/%d/root", pid) + } else { + root, cleanup, err = s.mountSnapshotForContainer(ctx, con) + if err != nil { + s.logger.Errorf("Could not mount snapshot: %s", err) + return + } + } + } + + // TODO: ideally this should use securejoin.SecureJoin() to sanitize inputs but securejoin can't work with afero.MemMapFs yet + // so it makes testing difficult + filePath = path.Join(root, srcPath) + + _, err = s.fs.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + err = errdefs.NewNotFound(err) + return + } + s.logger.Errorf("Error statting %s: %s", filePath, err) + return + } + + return +} + +func (s *service) WriteFilesAsTarArchive(filePath string, writer io.Writer, slashDot bool) error { + cmd, err := s.tarCreator.CreateTarCommand(filePath, slashDot) + if err != nil { + return err + } + cmd.SetStdout(writer) + return cmd.Run() +} + +func (s *service) mountSnapshotForContainer(ctx context.Context, con containerd.Container) (string, func(), error) { + cinfo, err := con.Info(ctx) + if err != nil { + return "", nil, err + } + snapKey := cinfo.SnapshotKey + + mounts, err := s.client.ListSnapshotMounts(ctx, snapKey) + if err != nil { + return "", nil, err + } + + tempDir, err := afero.TempDir(s.fs, "", "mount-snapshot") + if err != nil { + return "", nil, err + } + + // Mount the snapshot + if err := s.client.MountAll(mounts, tempDir); err != nil { + cleanup := func() { + s.fs.RemoveAll(tempDir) + } + return "", cleanup, err + } + + cleanup := func() { + s.client.Unmount(tempDir, 0) + s.fs.RemoveAll(tempDir) + } + + return tempDir, cleanup, nil +} diff --git a/pkg/service/container/get_archive_test.go b/pkg/service/container/get_archive_test.go new file mode 100644 index 00000000..23639f0e --- /dev/null +++ b/pkg/service/container/get_archive_test.go @@ -0,0 +1,253 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + "fmt" + "os" + pathutil "path" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/containers" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/mount" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_ecc" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_http" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/spf13/afero" +) + +var _ = Describe("Container Get Archive API", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + fs afero.Fs + tarCreator *mocks_archive.MockTarCreator + con *mocks_container.MockContainer + task *mocks_container.MockTask + cid string + mockPid uint32 + mockPath string + mockWriter *mocks_http.MockResponseWriter + mockCmd *mocks_ecc.MockExecCmd + s *service + containerPath string + ) + BeforeEach(func() { + ctx = context.Background() + cid = "test123" + mockPid = 1234 + mockPath = "/path/to/files" + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + fs = afero.NewMemMapFs() + tarCreator = mocks_archive.NewMockTarCreator(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + task = mocks_container.NewMockTask(mockCtrl) + mockWriter = mocks_http.NewMockResponseWriter(mockCtrl) + mockCmd = mocks_ecc.NewMockExecCmd(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + s = &service{ + client: cdClient, + nctlContainerSvc: mockNerdctlService{ncClient, nil}, + logger: logger, + fs: fs, + tarCreator: tarCreator, + } + containerPath = pathutil.Join(fmt.Sprintf("/proc/%d/root", mockPid), mockPath) + }) + Context("GetPathToFilesInContainer", func() { + It("should not return any errors on success", func() { + err := fs.MkdirAll(pathutil.Dir(containerPath), os.ModeDir) + Expect(err).Should(BeNil()) + + _, err = fs.Create(containerPath) + Expect(err).Should(BeNil()) + + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "running"}, nil) + task.EXPECT().Pid().Return(mockPid) + + path, cleanup, err := s.GetPathToFilesInContainer(ctx, cid, mockPath) + Expect(err).Should(BeNil()) + Expect(path).Should(Equal(containerPath)) + Expect(cleanup).Should(BeNil()) + }) + It("should pass through errors from getContainer", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return(nil, fmt.Errorf("getContainer error")) + logger.EXPECT().Errorf("failed to search container: %s. error: %s", cid, "getContainer error") + logger.EXPECT().Errorf("Error getting container: %s", gomock.Any()) + + path, cleanup, err := s.GetPathToFilesInContainer(ctx, cid, mockPath) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("getContainer error")) + Expect(path).Should(BeEmpty()) + Expect(cleanup).Should(BeNil()) + }) + It("should return a NotFound error if the file does not exist inside the container", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "running"}, nil) + task.EXPECT().Pid().Return(mockPid) + + path, cleanup, err := s.GetPathToFilesInContainer(ctx, cid, mockPath) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + Expect(path).Should(Equal(containerPath)) + Expect(cleanup).Should(BeNil()) + }) + It("should mount snapshot layers if the container has no task", func() { + mockSnapKey := "123" + mockMounts := []mount.Mount{ + {}, + } + var mountDir string + var snapPath string + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(nil, cerrdefs.ErrNotFound) + con.EXPECT().Info(ctx).Return(containers.Container{SnapshotKey: mockSnapKey}, nil) + cdClient.EXPECT().ListSnapshotMounts(ctx, mockSnapKey).Return(mockMounts, nil) + cdClient.EXPECT().MountAll(mockMounts, gomock.Any()).DoAndReturn(func(mounts []mount.Mount, tmpDir string) error { + mountDir = tmpDir + snapPath = pathutil.Join(mountDir, mockPath) + err := fs.MkdirAll(snapPath, os.ModeDir) + Expect(err).Should(BeNil()) + return nil + }) + + path, cleanup, err := s.GetPathToFilesInContainer(ctx, cid, mockPath) + + Expect(err).Should(BeNil()) + Expect(path).Should(HavePrefix(pathutil.Join(os.TempDir(), "mount-snapshot"))) + Expect(path).Should(HaveSuffix(mockPath)) + _, err = fs.Stat(snapPath) + Expect(err).Should(BeNil()) + Expect(cleanup).ShouldNot(BeNil()) + + cdClient.EXPECT().Unmount(gomock.Any(), 0).Return(nil) + + cleanup() + _, err = fs.Stat(snapPath) + Expect(err).ShouldNot(BeNil()) + Expect(errors.Is(err, os.ErrNotExist)).Should(BeTrue()) + _, err = fs.Stat(mountDir) + Expect(err).ShouldNot(BeNil()) + Expect(errors.Is(err, os.ErrNotExist)).Should(BeTrue()) + }) + It("should mount snapshot layers if the container is not running", func() { + mockSnapKey := "123" + mockMounts := []mount.Mount{ + {}, + } + var mountDir string + var snapPath string + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "stopped"}, nil) + con.EXPECT().Info(ctx).Return(containers.Container{SnapshotKey: mockSnapKey}, nil) + cdClient.EXPECT().ListSnapshotMounts(ctx, mockSnapKey).Return(mockMounts, nil) + cdClient.EXPECT().MountAll(mockMounts, gomock.Any()).DoAndReturn(func(mounts []mount.Mount, tmpDir string) error { + mountDir = tmpDir + snapPath = pathutil.Join(mountDir, mockPath) + err := fs.MkdirAll(snapPath, os.ModeDir) + Expect(err).Should(BeNil()) + return nil + }) + + path, cleanup, err := s.GetPathToFilesInContainer(ctx, cid, mockPath) + + Expect(err).Should(BeNil()) + Expect(path).Should(HavePrefix(pathutil.Join(os.TempDir(), "mount-snapshot"))) + Expect(path).Should(HaveSuffix(mockPath)) + _, err = fs.Stat(snapPath) + Expect(err).Should(BeNil()) + Expect(cleanup).ShouldNot(BeNil()) + + cdClient.EXPECT().Unmount(gomock.Any(), 0).Return(nil) + + cleanup() + _, err = fs.Stat(snapPath) + Expect(err).ShouldNot(BeNil()) + Expect(errors.Is(err, os.ErrNotExist)).Should(BeTrue()) + _, err = fs.Stat(mountDir) + Expect(err).ShouldNot(BeNil()) + Expect(errors.Is(err, os.ErrNotExist)).Should(BeTrue()) + }) + It("should cleanup the tempdir if it fails to mount the snapshot layers", func() { + mockSnapKey := "123" + mockMounts := []mount.Mount{ + {}, + } + var mountDir string + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(nil, cerrdefs.ErrNotFound) + con.EXPECT().Info(ctx).Return(containers.Container{SnapshotKey: mockSnapKey}, nil) + cdClient.EXPECT().ListSnapshotMounts(ctx, mockSnapKey).Return(mockMounts, nil) + cdClient.EXPECT().MountAll(mockMounts, gomock.Any()).DoAndReturn(func(mounts []mount.Mount, tmpDir string) error { + mountDir = tmpDir + return fmt.Errorf("MountAll error") + }) + logger.EXPECT().Errorf("Could not mount snapshot: %s", gomock.Any()) + + path, cleanup, err := s.GetPathToFilesInContainer(ctx, cid, mockPath) + + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("MountAll error")) + Expect(path).Should(BeEmpty()) + _, err = fs.Stat(mountDir) + Expect(err).Should(BeNil()) + Expect(cleanup).ShouldNot(BeNil()) + + cdClient.EXPECT().Unmount(gomock.Any(), 0).Return(nil) + + cleanup() + _, err = fs.Stat(mountDir) + Expect(err).ShouldNot(BeNil()) + Expect(errors.Is(err, os.ErrNotExist)).Should(BeTrue()) + }) + }) + Context("WriteFilesAsTarArchive", func() { + It("should return no error on success", func() { + tarCreator.EXPECT().CreateTarCommand(mockPath, false).Return(mockCmd, nil) + mockCmd.EXPECT().SetStdout(mockWriter) + mockCmd.EXPECT().Run().Return(nil) + + err := s.WriteFilesAsTarArchive(mockPath, mockWriter, false) + Expect(err).Should(BeNil()) + }) + It("should pass through errors from CreateTarCommand", func() { + tarCreator.EXPECT().CreateTarCommand(mockPath, false).Return(nil, fmt.Errorf("CreateTarCommand error")) + + err := s.WriteFilesAsTarArchive(mockPath, mockWriter, false) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("CreateTarCommand error")) + }) + It("should pass through errors from cmd.Run", func() { + tarCreator.EXPECT().CreateTarCommand(mockPath, false).Return(mockCmd, nil) + mockCmd.EXPECT().SetStdout(mockWriter) + mockCmd.EXPECT().Run().Return(fmt.Errorf("cmd.Run error")) + + err := s.WriteFilesAsTarArchive(mockPath, mockWriter, false) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("cmd.Run error")) + }) + }) +}) diff --git a/pkg/service/container/inspect.go b/pkg/service/container/inspect.go new file mode 100644 index 00000000..69ec23ef --- /dev/null +++ b/pkg/service/container/inspect.go @@ -0,0 +1,125 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +const networkPrefix = "unknown-eth" + +func (s *service) Inspect(ctx context.Context, cid string) (*types.Container, error) { + c, err := s.getContainer(ctx, cid) + if err != nil { + return nil, err + } + + inspect, err := s.nctlContainerSvc.InspectContainer(ctx, c) + if err != nil { + return nil, err + } + + // translate to a finch-daemon container inspect type + cont := types.Container{ + ID: inspect.ID, + Created: inspect.Created, + Path: inspect.Path, + Args: inspect.Args, + State: inspect.State, + Image: inspect.Image, + ResolvConfPath: inspect.ResolvConfPath, + HostnamePath: inspect.HostnamePath, + LogPath: inspect.LogPath, + Name: fmt.Sprintf("/%s", inspect.Name), + RestartCount: inspect.RestartCount, + Driver: inspect.Driver, + Platform: inspect.Platform, + AppArmorProfile: inspect.AppArmorProfile, + Mounts: inspect.Mounts, + NetworkSettings: inspect.NetworkSettings, + } + + cont.Config = &types.ContainerConfig{ + Hostname: inspect.Config.Hostname, + User: inspect.Config.User, + AttachStdin: inspect.Config.AttachStdin, + ExposedPorts: inspect.Config.ExposedPorts, + Tty: false, // TODO: Tty is always false until attach supports stdin with tty + Env: inspect.Config.Env, + Cmd: inspect.Config.Cmd, + Image: inspect.Image, + Volumes: inspect.Config.Volumes, + WorkingDir: inspect.Config.WorkingDir, + Entrypoint: inspect.Config.Entrypoint, + Labels: inspect.Config.Labels, + } + + l, err := c.Labels(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get container labels: %s", err) + } + updateNetworkSettings(ctx, cont.NetworkSettings, l) + + //make sure it passes the default time value for time fields otherwise the goclient fails. + if inspect.Created == "" { + cont.Created = "0001-01-01T00:00:00Z" + } + + if inspect.State != nil && inspect.State.FinishedAt == "" { + cont.State.FinishedAt = "0001-01-01T00:00:00Z" + } + + return &cont, nil +} + +// updateNetworkSettings updates the settings in the network to match that +// of docker as docker identifies networks by their name in "NetworkSettings", +// but nerdctl uses a sequential ordering "unknown-eth0", "unknown-eth1",... +// we use container labels to find corresponding name for each network in "NetworkSettings" +func updateNetworkSettings(ctx context.Context, ns *dockercompat.NetworkSettings, labels map[string]string) error { + if ns != nil && ns.Networks != nil { + networks := map[string]*dockercompat.NetworkEndpointSettings{} + + for network, settings := range ns.Networks { + networkName := getNetworkName(labels, network) + networks[networkName] = settings + } + ns.Networks = networks + } + return nil +} + +// getNetworkName gets network name from container labels using the index specified by the network prefix. +// returns the default prefix if network name was not found. +func getNetworkName(lab map[string]string, network string) string { + namesJSON, ok := lab[labels.Networks] + if !ok { + return network + } + var names []string + if err := json.Unmarshal([]byte(namesJSON), &names); err != nil { + return network + } + + if strings.HasPrefix(network, networkPrefix) { + prefixLen := len(networkPrefix) + index, err := strconv.ParseUint(network[prefixLen:], 10, 64) + if err != nil { + return network + } + if int(index) < len(names) { + return names[index] + } + } + + return network +} diff --git a/pkg/service/container/inspect_test.go b/pkg/service/container/inspect_test.go new file mode 100644 index 00000000..fabb3270 --- /dev/null +++ b/pkg/service/container/inspect_test.go @@ -0,0 +1,142 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + + "github.com/containerd/containerd" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to container inspect API +var _ = Describe("Container Inspect API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + con *mocks_container.MockContainer + cid string + img string + inspect dockercompat.Container + ret types.Container + service container.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + cid = "123" + img = "test-image" + inspect = dockercompat.Container{ + ID: cid, + Created: "2023-06-01", + Path: "/bin/sh", + Args: []string{"echo", "hello"}, + Image: img, + Name: "test-cont", + Config: &dockercompat.Config{ + Hostname: "test-hostname", + User: "test-user", + AttachStdin: false, + }, + } + ret = types.Container{ + ID: cid, + Created: "2023-06-01", + Path: "/bin/sh", + Args: []string{"echo", "hello"}, + Image: img, + Name: "/test-cont", + Config: &types.ContainerConfig{ + Hostname: "test-hostname", + User: "test-user", + AttachStdin: false, + Tty: false, + Image: img, + }, + } + + service = NewService(cdClient, mockNerdctlService{ncClient, nil}, logger, nil, nil, nil) + }) + Context("service", func() { + It("should return the inspect object upon success", func() { + // search container method returns one container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + + ncClient.EXPECT().InspectContainer(gomock.Any(), con).Return( + &inspect, nil) + + con.EXPECT().Labels(gomock.Any()).Return(nil, nil) + + // service should return inspect object + result, err := service.Inspect(ctx, cid) + Expect(*result).Should(Equal(ret)) + Expect(err).Should(BeNil()) + }) + It("should return NotFound error if container was not found", func() { + // search container method returns no container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // service should return a NotFound error + result, err := service.Inspect(ctx, cid) + Expect(result).Should(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return an error if multiple containers were found for the given Id", func() { + // search container method returns multiple containers + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con, con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // service should return an error + result, err := service.Inspect(ctx, cid) + Expect(result).Should(BeNil()) + Expect(err).ShouldNot(BeNil()) + }) + It("should return an error if search container method failed", func() { + // search container method returns no container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + nil, errors.New("error message")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + + // service should return an error + result, err := service.Inspect(ctx, cid) + Expect(result).Should(BeNil()) + Expect(err).ShouldNot(BeNil()) + }) + It("should return an error if the backend inspect method failed", func() { + // search container method returns no container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + + ncClient.EXPECT().InspectContainer(gomock.Any(), con).Return( + nil, errors.New("error message")) + + // service should return an error + result, err := service.Inspect(ctx, cid) + Expect(result).Should(BeNil()) + Expect(err).ShouldNot(BeNil()) + }) + }) +}) diff --git a/pkg/service/container/list.go b/pkg/service/container/list.go new file mode 100644 index 00000000..e097c9aa --- /dev/null +++ b/pkg/service/container/list.go @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +func (s *service) List(ctx context.Context, listOpts ncTypes.ContainerListOptions) ([]types.ContainerListItem, error) { + ncContainers, err := s.nctlContainerSvc.ListContainers(ctx, listOpts) + if err != nil { + return nil, err + } + containers := []types.ContainerListItem{} + for _, ncc := range ncContainers { + ncc.Names = fmt.Sprintf("/%s", ncc.Names) + + c, err := s.getContainer(ctx, ncc.ID) + if err != nil { + return nil, err + } + + ci, err := s.nctlContainerSvc.InspectContainer(ctx, c) + if err != nil { + return nil, err + } + + cli := types.ContainerListItem{ + Id: ncc.ID, + Names: []string{ncc.Names}, + Image: ncc.Image, + CreatedAt: ncc.CreatedAt.Unix(), + State: ci.State.Status, + Labels: ncc.Labels, + NetworkSettings: ci.NetworkSettings, + Mounts: ci.Mounts, + } + + l, err := c.Labels(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get container labels: %s", err) + } + updateNetworkSettings(ctx, cli.NetworkSettings, l) + + containers = append(containers, cli) + } + return containers, nil +} diff --git a/pkg/service/container/list_test.go b/pkg/service/container/list_test.go new file mode 100644 index 00000000..66e56cde --- /dev/null +++ b/pkg/service/container/list_test.go @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + "time" + + "github.com/containerd/containerd" + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + ncContainer "github.com/containerd/nerdctl/pkg/cmd/container" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to container list API +var _ = Describe("Container List API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + listOpts ncTypes.ContainerListOptions + created time.Time + containers []ncContainer.ListItem + tarExtractor *mocks_archive.MockTarExtractor + service container.Service + con *mocks_container.MockContainer + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + listOpts = ncTypes.ContainerListOptions{} + created = time.Now() + containers = []ncContainer.ListItem{ + {ID: "id1", Names: "name1", Image: "img1", CreatedAt: created, Labels: nil}, + {ID: "id2", Names: "name2", Image: "img2", CreatedAt: created, Labels: nil}, + } + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + + service = NewService(cdClient, mockNerdctlService{ncClient, nil}, logger, nil, nil, tarExtractor) + }) + Context("service", func() { + It("should successfully list containers", func() { + expectedNS := &dockercompat.NetworkSettings{ + DefaultNetworkSettings: dockercompat.DefaultNetworkSettings{ + IPAddress: "ip-test", + }, + } + + ncClient.EXPECT().ListContainers(ctx, listOpts).Return( + containers, nil) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).AnyTimes().Return( + []containerd.Container{con}, nil) + ncClient.EXPECT().InspectContainer(gomock.Any(), gomock.Any()).AnyTimes().Return( + &dockercompat.Container{ + NetworkSettings: expectedNS, + Mounts: nil, + State: &dockercompat.ContainerState{ + Status: "running", + }, + }, nil) + con.EXPECT().Labels(gomock.Any()).AnyTimes().Return(nil, nil) + + want := []types.ContainerListItem{ + {Id: "id1", Names: []string{"/name1"}, Image: "img1", CreatedAt: created.Unix(), State: "running", NetworkSettings: expectedNS, Mounts: nil}, + {Id: "id2", Names: []string{"/name2"}, Image: "img2", CreatedAt: created.Unix(), State: "running", NetworkSettings: expectedNS, Mounts: nil}, + } + got, err := service.List(ctx, listOpts) + Expect(err).Should(BeNil()) + Expect(got).Should(Equal(want)) + }) + It("should successfully list zero container", func() { + ncClient.EXPECT().ListContainers(ctx, listOpts).Return( + []ncContainer.ListItem{}, nil) + want := []types.ContainerListItem{} + got, err := service.List(ctx, listOpts) + Expect(err).Should(BeNil()) + Expect(got).Should(Equal(want)) + }) + It("should return error when nerdctl returns error", func() { + mockErr := errors.New("error while listing containers") + ncClient.EXPECT().ListContainers(ctx, listOpts).Return( + []ncContainer.ListItem{}, mockErr) + got, err := service.List(ctx, listOpts) + Expect(err).Should(Equal(mockErr)) + Expect(got).Should(BeEmpty()) + }) + }) +}) diff --git a/pkg/service/container/logs.go b/pkg/service/container/logs.go new file mode 100644 index 00000000..42c6f218 --- /dev/null +++ b/pkg/service/container/logs.go @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "io" + "strconv" + + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/moby/moby/pkg/stdcopy" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// Logs attaches the stdout and stderr to the container using nerdctl logs +func (s *service) Logs(ctx context.Context, cid string, opts *types.LogsOptions) error { + // fetch container + con, err := s.getContainer(ctx, cid) + if err != nil { + return err + } + s.logger.Infof("getting logs for container: %s", con.ID()) + + // set up io streams + outStream, errStream, stopChannel, printSuccessResp, err := opts.GetStreams() + if err != nil { + return err + } + + if opts.MuxStreams { + errStream = stdcopy.NewStdWriter(errStream, stdcopy.Stderr) + outStream = stdcopy.NewStdWriter(outStream, stdcopy.Stdout) + } + var ( + stdout, stderr io.Writer + ) + if opts.Stdout { + stdout = outStream + } + if opts.Stderr { + stderr = errStream + } + + tail := uint64(0) + if opts.Tail != "all" && len(opts.Tail) != 0 { + tail, err = strconv.ParseUint(opts.Tail, 10, 16) + } + + // assign until "" if zero is returned as until = 0 (is default to docker to show everything) + // but nerdctl will interpret that as a time of 0 + until := strconv.FormatInt(opts.Until, 10) + if until == "0" { + until = "" + } + + // assemble log options and call attachLogs (based off of nerdctl's container.Logs) + logOpts := ncTypes.ContainerLogsOptions{ + Stdout: stdout, + Stderr: stderr, + GOptions: ncTypes.GlobalCommandOptions{}, + Follow: opts.Follow, + Timestamps: opts.Timestamps, + Tail: uint(tail), + Since: strconv.FormatInt(opts.Since, 10), + Until: until, + } + err = s.attachLogs(ctx, con, logOpts, stopChannel, printSuccessResp) + if err != nil { + s.logger.Debugf("failed to retrieve logs for the container: %s", cid) + return err + } + return nil +} diff --git a/pkg/service/container/logs_test.go b/pkg/service/container/logs_test.go new file mode 100644 index 00000000..0122390e --- /dev/null +++ b/pkg/service/container/logs_test.go @@ -0,0 +1,257 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + + "github.com/containerd/containerd" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/nerdctl/pkg/labels/k8slabels" + "github.com/containerd/typeurl/v2" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Container Logs API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + tarExtractor *mocks_archive.MockTarExtractor + service container.Service + + mockWriter *bytes.Buffer + stopChannel chan os.Signal + setupStreams func() (io.Writer, io.Writer, chan os.Signal, func(), error) + cid string + ) + BeforeEach(func() { + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + service = NewService(cdClient, mockNerdctlService{ncClient, nil}, logger, nil, nil, tarExtractor) + + mockWriter = new(bytes.Buffer) + stopChannel = make(chan os.Signal, 1) + signal.Notify(stopChannel, syscall.SIGTERM, syscall.SIGINT) + setupStreams = func() (io.Writer, io.Writer, chan os.Signal, func(), error) { + return mockWriter, mockWriter, stopChannel, func() {}, nil + } + cid = "test-container" + }) + Context("service", func() { + It("should return an error if opts.GetStreams returns an error", func() { + // set up expected mocks, errors and the setupstreams to return an error + con := mocks_container.NewMockContainer(mockCtrl) + logger.EXPECT().Infof("getting logs for container: %s", cid).Return() + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + con.EXPECT().ID().Return(cid) + expErr := fmt.Errorf("error") + setupStreams = func() (io.Writer, io.Writer, chan os.Signal, func(), error) { + return nil, nil, nil, nil, expErr + } + // set up options + opts := types.LogsOptions{ + GetStreams: setupStreams, + Stdout: true, + Stderr: true, + Follow: true, + Since: 0, + Until: 0, + Timestamps: false, + Tail: "", + MuxStreams: false, + } + + // run function and assertions + err := service.Logs(ctx, cid, &opts) + Expect(err).Should(Equal(expErr)) + }) + It("should return an error if the datastore cannot be found", func() { + // set up mocks and expected errors + expErr := "error data store not found" + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Infof("getting logs for container: %s", cid).Return() + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().GetDataStore().Return("", fmt.Errorf(expErr)) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + // set up options + opts := types.LogsOptions{ + GetStreams: setupStreams, + Stdout: true, + Stderr: true, + Follow: true, + Since: 0, + Until: 0, + Timestamps: false, + Tail: "", + MuxStreams: true, + } + + // run function and assertions + err := service.Logs(ctx, cid, &opts) + Expect(err.Error()).Should(ContainSubstring(expErr)) + }) + It("should return a not found error if a container can't be found", func() { + // set up mocks + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + + // set up options + opts := types.LogsOptions{ + GetStreams: setupStreams, + Stdout: true, + Stderr: true, + Follow: true, + Since: 0, + Until: 0, + Timestamps: false, + Tail: "", + MuxStreams: true, + } + + // run function and assertions + err := service.Logs(ctx, cid, &opts) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should successfully return logs without follow", func() { + // set up mocks + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Infof("getting logs for container: %s", cid).Return() + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().GetDataStore().Return("", nil) + con.EXPECT().Labels(gomock.Any()).Return(map[string]string{labels.Namespace: "test"}, nil) + // construct typeURL.Any object as getLogPath calls it to unmarshall and get a value + type testJSONObj struct{ LogPath string } + testJSON := &testJSONObj{LogPath: ""} + testAny, _ := typeurl.MarshalAny(testJSON) + // continue setting up mocks + con.EXPECT().Extensions(gomock.Any()).Return(map[string]typeurl.Any{ + k8slabels.ContainerMetadataExtension: testAny, + }, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Running) + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().LoggingInitContainerLogViewer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + ncClient.EXPECT().LoggingPrintLogsTo(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + // set up options + opts := types.LogsOptions{ + GetStreams: setupStreams, + Stdout: true, + Stderr: true, + Follow: false, + Since: 0, + Until: 0, + Timestamps: false, + Tail: "", + MuxStreams: true, + } + + // run function and assertions + err := service.Logs(ctx, cid, &opts) + Expect(err).Should(BeNil()) + }) + It("should successfully log a container with follow=1, but not follow when stopped", func() { + // set up mocks + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Infof("getting logs for container: %s", cid).Return() + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().GetDataStore().Return("", nil) + con.EXPECT().Labels(gomock.Any()).Return(map[string]string{labels.Namespace: "test"}, nil) + // construct typeURL.Any object as getLogPath calls it to unmarshall and get a value + type testJSONObj struct{ LogPath string } + testJSON := &testJSONObj{LogPath: ""} + testAny, _ := typeurl.MarshalAny(testJSON) + // continue setting up mocks + con.EXPECT().Extensions(gomock.Any()).Return(map[string]typeurl.Any{ + k8slabels.ContainerMetadataExtension: testAny, + }, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Stopped) + con.EXPECT().Task(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")) + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().LoggingInitContainerLogViewer(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + ncClient.EXPECT().LoggingPrintLogsTo(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + // set up options + opts := types.LogsOptions{ + GetStreams: setupStreams, + Stdout: true, + Stderr: true, + Follow: true, + Since: 0, + Until: 0, + Timestamps: false, + Tail: "", + MuxStreams: true, + } + + // run function and assertions + err := service.Logs(ctx, cid, &opts) + Expect(err).Should(BeNil()) + }) + It("should return an error with follow and a running container when failed to get wait channel", func() { + // set up expected error and mocks + expErr := "error task wait channel" + con := mocks_container.NewMockContainer(mockCtrl) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return([]containerd.Container{con}, nil) + logger.EXPECT().Infof("getting logs for container: %s", cid).Return() + con.EXPECT().ID().Return(cid) + ncClient.EXPECT().GetDataStore().Return("", nil) + con.EXPECT().Labels(gomock.Any()).Return(map[string]string{labels.Namespace: "test"}, nil) + // construct typeURL.Any object as getLogPath calls it to unmarshall and get a value + type testJSONObj struct{ LogPath string } + testJSON := &testJSONObj{LogPath: ""} + testAny, _ := typeurl.MarshalAny(testJSON) + // continue setting up mocks + con.EXPECT().Extensions(gomock.Any()).Return(map[string]typeurl.Any{ + k8slabels.ContainerMetadataExtension: testAny, + }, nil) + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Running) + cdClient.EXPECT().GetContainerTaskWait(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, fmt.Errorf(expErr)) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Return() + + // set up options + opts := types.LogsOptions{ + GetStreams: setupStreams, + Stdout: true, + Stderr: true, + Follow: true, + Since: 0, + Until: 0, + Timestamps: false, + Tail: "", + MuxStreams: true, + } + + // run function and assertions + err := service.Logs(ctx, cid, &opts) + Expect(err.Error()).Should(ContainSubstring(expErr)) + }) + }) +}) diff --git a/pkg/service/container/put_archive.go b/pkg/service/container/put_archive.go new file mode 100644 index 00000000..627ef6ab --- /dev/null +++ b/pkg/service/container/put_archive.go @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + + "github.com/containerd/containerd" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/docker/docker/pkg/archive" + "github.com/docker/docker/pkg/idtools" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// ExtractArchiveInContainer extracts the given tar archive to the specified location in the +// filesystem of this container. The given path must be of a directory in the +// container. If it is not, the error will be an errdefs.InvalidFormat. If +// noOverwriteDirNonDir is true then it will be an error if unpacking the +// given content would cause an existing directory to be replaced with a non- +// directory and vice versa. +// setting uidgid has no effect due to https://github.com/docker/docs/issues/17533#issuecomment-1588223056 +// Fix once the bug is resolved. + +func (s *service) ExtractArchiveInContainer(ctx context.Context, opts *types.PutArchiveOptions, body io.ReadCloser) error { + con, err := s.getContainer(ctx, opts.ContainerId) + if err != nil { + return err + } + // First check if the mount is a volume and readonly or in a readonly rootfs + err = s.isReadOnlyMount(ctx, con, opts.Path) + if err != nil { + return err + } + var ( + root string + cleanup func() + filePath string + ) + + task, err := con.Task(ctx, nil) + if err != nil { + // if the task is simply not found, we should try to mount the snapshot. any other type of error from Task() is fatal here. + if !cerrdefs.IsNotFound(err) { + s.logger.Errorf("Error getting task from container %s: %v", opts.ContainerId, err) + return err + } + root, cleanup, err = s.mountSnapshotForContainer(ctx, con) + if err != nil { + s.logger.Errorf("Could not mount snapshot: %v", err) + return err + } + } else { + var status containerd.Status + status, err = task.Status(ctx) + if err != nil { + s.logger.Errorf("Could not get task status: %v", err) + return err + } + if status.Status == containerd.Running { + pid := task.Pid() + // containerPath to the container's root filesystem. reference: + // https://github.com/containerd/nerdctl/blob/774b6e9ab69fadbcffb60297791db3f036231abf/pkg/containerutil/cp_linux.go#L44 + root = fmt.Sprintf("/proc/%d/root", pid) + } else { + root, cleanup, err = s.mountSnapshotForContainer(ctx, con) + if err != nil { + s.logger.Errorf("Could not mount snapshot: %v", err) + return err + } + } + } + if cleanup != nil { + defer cleanup() + } + // TODO: ideally this should use securejoin.SecureJoin() to sanitize inputs but securejoin can't work with afero.MemMapFs yet + // so it makes testing difficult + filePath = path.Join(root, opts.Path) + + stat, err := s.fs.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + err = errdefs.NewNotFound(err) + return err + } + s.logger.Errorf("Error statting %s: %s", filePath, err) + return err + } + if !stat.IsDir() { + return errdefs.NewInvalidFormat(fmt.Errorf("extraction point: %s is not a directory", filePath)) + } + tarOptions := &archive.TarOptions{ + NoOverwriteDirNonDir: opts.Overwrite, + IDMap: idtools.IdentityMapping{}, + } + return s.tarExtractor.ExtractCompressed(body, filePath, tarOptions) +} + +func (s *service) isReadOnlyMount(ctx context.Context, con containerd.Container, containerPath string) error { + filePath := filepath.Clean(containerPath) + spec, err := con.Spec(ctx) + if err != nil { + return err + } + if spec.Root.Readonly { + return errdefs.NewForbidden(fmt.Errorf("container rootfs: %s is marked read-only", spec.Root.Path)) + } + for _, mount := range spec.Mounts { + for _, option := range mount.Options { + // Check if path to copy is marked read-only + if option == "ro" && isParentDir(filePath, mount.Destination) { + return errdefs.NewForbidden(fmt.Errorf("mount point %s is marked read-only", filePath)) + } + } + } + return nil +} + +func isParentDir(filePath, potentialParent string) bool { + if filePath == potentialParent { + return true + } + if filePath == "/" { + return false + } + return isParentDir(path.Dir(filePath), potentialParent) +} diff --git a/pkg/service/container/put_archive_test.go b/pkg/service/container/put_archive_test.go new file mode 100644 index 00000000..35dd4c16 --- /dev/null +++ b/pkg/service/container/put_archive_test.go @@ -0,0 +1,366 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + "fmt" + "io" + "os" + pathutil "path" + "strings" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/containers" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/mount" + "github.com/docker/docker/pkg/archive" + "github.com/docker/docker/pkg/idtools" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/spf13/afero" +) + +type MockReadCloser struct { + io.Reader +} + +func (m *MockReadCloser) Close() error { + return nil +} + +var _ = Describe("Extract in container API", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + fs afero.Fs + tarExtractor *mocks_archive.MockTarExtractor + con *mocks_container.MockContainer + task *mocks_container.MockTask + cid string + mockPid uint32 + mockPath string + putArchiveOpts *types.PutArchiveOptions + s *service + containerPath string + mockReader io.ReadCloser + ) + BeforeEach(func() { + ctx = context.Background() + cid = "test123" + mockPid = 1234 + mockPath = "/path/to/files" + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + fs = afero.NewMemMapFs() + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + task = mocks_container.NewMockTask(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + s = &service{ + client: cdClient, + nctlContainerSvc: mockNerdctlService{ncClient, nil}, + logger: logger, + fs: fs, + tarExtractor: tarExtractor, + } + putArchiveOpts = &types.PutArchiveOptions{ + ContainerId: "test123", + Path: "/path/to/files", + Overwrite: false, + CopyUIDGID: false, + } + containerPath = pathutil.Join(fmt.Sprintf("/proc/%d/root", mockPid), mockPath) + mockReader = &MockReadCloser{strings.NewReader("Test tar archive")} + }) + Context("ExtractArchiveInContainer", func() { + It("should not return an error when container is running and path is writeable", func() { + err := fs.MkdirAll(containerPath, 0o755) + Expect(err).Should(BeNil()) + _, err = fs.Stat(containerPath) + Expect(err).Should(BeNil()) + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "running"}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "rw"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: false, + }, + }, nil) + task.EXPECT().Pid().Return(mockPid) + tarExtractor.EXPECT().ExtractCompressed(mockReader, containerPath, &archive.TarOptions{ + NoOverwriteDirNonDir: false, + IDMap: idtools.IdentityMapping{}, + }).Return(nil) + err = s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(err).Should(BeNil()) + }) + It("should return an error when container is running and volume is read-only", func() { + err := fs.MkdirAll(containerPath, 0o755) + Expect(err).Should(BeNil()) + _, err = fs.Stat(containerPath) + Expect(err).Should(BeNil()) + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "running"}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "ro"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: false, + }, + }, nil) + err = s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(errdefs.IsForbiddenError(err)).Should(Equal(true)) + }) + It("should return an error when container is running and rootfs is read-only", func() { + err := fs.MkdirAll(containerPath, 0o755) + Expect(err).Should(BeNil()) + _, err = fs.Stat(containerPath) + Expect(err).Should(BeNil()) + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "running"}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "rw"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: true, + }, + }, nil) + err = s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(errdefs.IsForbiddenError(err)).Should(Equal(true)) + }) + It("should return an error when path is not a directory", func() { + err := fs.MkdirAll(containerPath, 0o755) + Expect(err).Should(BeNil()) + + _, err = fs.Create(containerPath) + Expect(err).Should(BeNil()) + + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "running"}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "rw"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: false, + }, + }, nil) + task.EXPECT().Pid().Return(mockPid) + err = s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(errdefs.IsInvalidFormat(err)).Should(Equal(true)) + }) + It("should not return an error when container is stopped and success", func() { + mockSnapKey := "123" + mockMounts := []mount.Mount{ + {}, + } + var mountDir string + var snapPath string + err := fs.MkdirAll(containerPath, 0o755) + Expect(err).Should(BeNil()) + _, err = fs.Stat(containerPath) + Expect(err).Should(BeNil()) + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "stopped"}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "rw"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: false, + }, + }, nil) + con.EXPECT().Info(ctx).Return(containers.Container{SnapshotKey: mockSnapKey}, nil) + cdClient.EXPECT().ListSnapshotMounts(ctx, mockSnapKey).Return(mockMounts, nil) + cdClient.EXPECT().MountAll(mockMounts, gomock.Any()).DoAndReturn(func(mounts []mount.Mount, tmpDir string) error { + mountDir = tmpDir + snapPath = pathutil.Join(mountDir, mockPath) + err := fs.MkdirAll(snapPath, os.ModeDir) + Expect(err).Should(BeNil()) + return nil + }) + tarExtractor.EXPECT().ExtractCompressed(mockReader, gomock.Any(), &archive.TarOptions{ + NoOverwriteDirNonDir: false, + IDMap: idtools.IdentityMapping{}, + }).Return(nil) + cdClient.EXPECT().Unmount(gomock.Any(), 0) + err = s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(err).Should(BeNil()) + }) + It("should not return an error when task is not found", func() { + mockSnapKey := "123" + mockMounts := []mount.Mount{ + {}, + } + var mountDir string + var snapPath string + err := fs.MkdirAll(containerPath, 0o755) + Expect(err).Should(BeNil()) + _, err = fs.Stat(containerPath) + Expect(err).Should(BeNil()) + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(nil, cerrdefs.ErrNotFound) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "stopped"}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "rw"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: false, + }, + }, nil) + con.EXPECT().Info(ctx).Return(containers.Container{SnapshotKey: mockSnapKey}, nil) + cdClient.EXPECT().ListSnapshotMounts(ctx, mockSnapKey).Return(mockMounts, nil) + cdClient.EXPECT().MountAll(mockMounts, gomock.Any()).DoAndReturn(func(mounts []mount.Mount, tmpDir string) error { + mountDir = tmpDir + snapPath = pathutil.Join(mountDir, mockPath) + err := fs.MkdirAll(snapPath, os.ModeDir) + Expect(err).Should(BeNil()) + return nil + }) + tarExtractor.EXPECT().ExtractCompressed(mockReader, gomock.Any(), &archive.TarOptions{ + NoOverwriteDirNonDir: false, + IDMap: idtools.IdentityMapping{}, + }).Return(nil) + cdClient.EXPECT().Unmount(gomock.Any(), 0) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any(), gomock.Any()) + err = s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(err).Should(BeNil()) + }) + It("should return an error when task not found and error not not found", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "rw"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: false, + }, + }, nil) + con.EXPECT().Task(ctx, nil).Return(nil, errors.New("task finding error")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any(), gomock.Any()) + err := s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(err.Error()).Should(Equal("task finding error")) + }) + It("should return an error when task status returns an error ", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "rw"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: false, + }, + }, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "unknown"}, errors.New("status error")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + err := s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(err.Error()).Should(Equal("status error")) + }) + It("should return an error when searching a container returns an error ", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{}, errors.New("search error")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any(), gomock.Any()) + err := s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(err.Error()).Should(Equal("search error")) + }) + It("should return an error when error mounting snapshot", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{Status: "stopped"}, nil) + con.EXPECT().Spec(ctx).Return(&specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: mockPath, + Type: "bind", + Source: "/path/on/host", + Options: []string{"rbind", "rw"}, + }, + }, + Root: &specs.Root{ + Path: "rootfs", + Readonly: false, + }, + }, nil) + con.EXPECT().Info(ctx).Return(containers.Container{}, errors.New("error finding info")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + err := s.ExtractArchiveInContainer(ctx, putArchiveOpts, mockReader) + Expect(err.Error()).Should(Equal("error finding info")) + }) + }) +}) diff --git a/pkg/service/container/remove.go b/pkg/service/container/remove.go new file mode 100644 index 00000000..dc96d132 --- /dev/null +++ b/pkg/service/container/remove.go @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "errors" + "fmt" + + "github.com/containerd/nerdctl/pkg/cmd/container" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// Remove function deletes a container. It returns nil when it successfully removes the container. +func (s *service) Remove(ctx context.Context, cid string, force, removeVolumes bool) (err error) { + con, err := s.getContainer(ctx, cid) + if err != nil { + return err + } + + s.logger.Debugf("removing container: %s", con.ID()) + if err := s.nctlContainerSvc.RemoveContainer(ctx, con, force, removeVolumes); err != nil { + // if containers is running then return with proper error msg otherwise return the original error msg + if errors.As(err, &container.ErrContainerStatus{}) { + s.logger.Debugf("Container is in running or pausing state. Failed to remove container: %s", con.ID()) + err = errdefs.NewConflict(fmt.Errorf("%s. unpause/stop container first or force removal", err)) + return err + } + // failed to delete the container. log the error msg and return with error + s.logger.Errorf("Failed to remove container: %s. Error: %s", con.ID(), err.Error()) + return err + } + return nil +} diff --git a/pkg/service/container/remove_test.go b/pkg/service/container/remove_test.go new file mode 100644 index 00000000..c88b684b --- /dev/null +++ b/pkg/service/container/remove_test.go @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + ncContainer "github.com/containerd/nerdctl/pkg/cmd/container" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to container remove API +var _ = Describe("Container Remove API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + con *mocks_container.MockContainer + cid string + tarExtractor *mocks_archive.MockTarExtractor + service container.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + + service = NewService(cdClient, mockNerdctlService{ncClient, nil}, logger, nil, nil, tarExtractor) + }) + Context("service", func() { + It("should successfully remove the container", func() { + // set up the mock to return a container with no error while searching for containers + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + + // set up the mock to verify the remove container is called and proper error msg was logged + ncClient.EXPECT().RemoveContainer(ctx, con, false, false) + logger.EXPECT().Debugf("removing container: %s", cid) + + // service should not return any error + err := service.Remove(ctx, cid, false, false) + Expect(err).Should(BeNil()) + }) + It("should return internal error", func() { + // set up the mock to mimic there was an error while searching for the container + mockErr := fmt.Errorf("some error occured during container search") + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{}, mockErr) + logger.EXPECT().Errorf("failed to search container: %s. error: %s", cid, mockErr.Error()) + + // service should return with an error + err := service.Remove(ctx, cid, false, false) + Expect(err.Error()).Should(Equal(mockErr.Error())) + }) + It("should return no container found", func() { + // set up the mock to mimic there is no container with the provided container id + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{}, nil) + logger.EXPECT().Debugf("no such container: %s", cid) + + // service should return NotFound error + err := service.Remove(ctx, cid, false, false) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return multiple containers found error", func() { + // set up the mock to return multiple containers that matches the container id prefix provided by the user + firstCon := mocks_container.NewMockContainer(mockCtrl) + firstCon.EXPECT().ID().Return(cid + "_1").AnyTimes() + secondCon := mocks_container.NewMockContainer(mockCtrl) + secondCon.EXPECT().ID().Return(cid + "_2").AnyTimes() + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{firstCon, secondCon}, nil) + logger.EXPECT().Debugf("multiple IDs found with provided prefix: %s, total containers found: %d", + cid, 2) + + // service should return error + err := service.Remove(ctx, cid, false, false) + Expect(err).Should(Not(BeNil())) + }) + It("should return conflict error as container is running", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + // set up the mock to mimic the container is running + ncClient.EXPECT().RemoveContainer(gomock.Any(), con, gomock.Any(), gomock.Any()). + Return(ncContainer.NewStatusError(cid, containerd.Running)) + logger.EXPECT().Debugf("removing container: %s", cid) + logger.EXPECT().Debugf("Container is in running or pausing state. Failed to remove container: %s", cid) + + // service should return conflict error + err := service.Remove(ctx, cid, false, false) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + }) + + It("should return error due to failure in deleting a container", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + // set up the mock to mimic the container delete failed in containerd + mockErr := fmt.Errorf("some random error to delete") + ncClient.EXPECT().RemoveContainer(gomock.Any(), con, gomock.Any(), gomock.Any()). + Return(mockErr) + logger.EXPECT().Debugf("removing container: %s", cid) + logger.EXPECT().Errorf("Failed to remove container: %s. Error: %s", con.ID(), mockErr.Error()) + err := service.Remove(ctx, cid, false, false) + // service should return error + Expect(err).Should(Not(BeNil())) + }) + + }) +}) diff --git a/pkg/service/container/rename.go b/pkg/service/container/rename.go new file mode 100644 index 00000000..9acc02d2 --- /dev/null +++ b/pkg/service/container/rename.go @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// Rename function renames a running container. It returns nil when it successfully renames the container. +func (s *service) Rename(ctx context.Context, cid string, newName string, opts ncTypes.ContainerRenameOptions) error { + con, err := s.getContainer(ctx, newName) + if con != nil { + err = errdefs.NewConflict(fmt.Errorf("Container with name %s already exists", newName)) + s.logger.Errorf("Failed to rename container: %s. Error: %v", cid, err) + return err + } + + con, err = s.getContainer(ctx, cid) + if err != nil { + return err + } + if err = s.nctlContainerSvc.RenameContainer(ctx, con, newName, opts); err != nil { + s.logger.Errorf("Failed to rename container: %s. Error: %v", cid, err) + return err + } + s.logger.Debugf("successfully renamed %s to %s", cid, newName) + return nil +} diff --git a/pkg/service/container/rename_test.go b/pkg/service/container/rename_test.go new file mode 100644 index 00000000..bcb6ce8f --- /dev/null +++ b/pkg/service/container/rename_test.go @@ -0,0 +1,117 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + ncTypes "github.com/containerd/nerdctl/pkg/api/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to container rename API +var _ = Describe("Container Rename API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + con *mocks_container.MockContainer + cid string + tarExtractor *mocks_archive.MockTarExtractor + service container.Service + testContainerName string + opts ncTypes.ContainerRenameOptions + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + + testContainerName = "testContainerName" + service = NewService(cdClient, mockNerdctlService{ncClient, nil}, logger, nil, nil, tarExtractor) + opts = ncTypes.ContainerRenameOptions{ + GOptions: ncTypes.GlobalCommandOptions{}, + Stdout: nil, + } + }) + Context("service", func() { + It("should not return any error", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), testContainerName).Return( + []containerd.Container{}, nil) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + ncClient.EXPECT().RenameContainer(ctx, con, testContainerName, gomock.Any()) + logger.EXPECT().Debugf("no such container: %s", testContainerName) + logger.EXPECT().Debugf("successfully renamed %s to %s", cid, testContainerName) + + err := service.Rename(ctx, cid, testContainerName, opts) + Expect(err).Should(BeNil()) + }) + It("should return not found error", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{}, nil).AnyTimes() + logger.EXPECT().Debugf("no such container: %s", testContainerName) + logger.EXPECT().Debugf("no such container: %s", cid) + + err := service.Rename(ctx, cid, testContainerName, opts) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return multiple containers found error", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), testContainerName).Return( + []containerd.Container{}, nil) + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con, con}, nil) + logger.EXPECT().Debugf("no such container: %s", testContainerName) + logger.EXPECT().Debugf("multiple IDs found with provided prefix: %s, total containers found: %d", cid, 2) + + // service should return error + err := service.Rename(ctx, cid, testContainerName, opts) + Expect(err).Should(Not(BeNil())) + }) + It("should return conflict error as container name is taken", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), testContainerName).Return( + []containerd.Container{con}, nil) + + expectedErr := errdefs.NewConflict(fmt.Errorf("Container with name %s already exists", testContainerName)) + logger.EXPECT().Errorf("Failed to rename container: %s. Error: %v", cid, expectedErr) + + // service should return conflict error. + err := service.Rename(ctx, cid, testContainerName, opts) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + }) + It("should fail due to nerdctl client error", func() { + // set up the mock to mimic an error occurred while stopping the container using nerdctl function + cdClient.EXPECT().SearchContainer(gomock.Any(), testContainerName).Return( + []containerd.Container{}, nil) + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + + expectedErr := fmt.Errorf("nerdctl error") + ncClient.EXPECT().RenameContainer(ctx, con, testContainerName, gomock.Any()).Return(expectedErr) + logger.EXPECT().Debugf("no such container: %s", testContainerName) + logger.EXPECT().Errorf("Failed to rename container: %s. Error: %v", cid, expectedErr) + + err := service.Rename(ctx, cid, testContainerName, opts) + Expect(err).Should(Equal(expectedErr)) + }) + }) +}) diff --git a/pkg/service/container/start.go b/pkg/service/container/start.go new file mode 100644 index 00000000..bfa36c93 --- /dev/null +++ b/pkg/service/container/start.go @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (s *service) Start(ctx context.Context, cid string) error { + cont, err := s.getContainer(ctx, cid) + if err != nil { + return err + } + if err := s.assertStartContainer(ctx, cont); err != nil { + return err + } + // start the containers and if error occurs then return error otherwise return nil + s.logger.Debugf("starting container: %s", cid) + if err := s.nctlContainerSvc.StartContainer(ctx, cont); err != nil { + s.logger.Errorf("Failed to start container: %s. Error: %v", cid, err) + return err + } + s.logger.Debugf("successfully started: %s", cid) + return nil +} + +func (s *service) assertStartContainer(ctx context.Context, c containerd.Container) error { + status := s.client.GetContainerStatus(ctx, c) + switch status { + case containerd.Running: + return errdefs.NewNotModified(fmt.Errorf("container already running")) + case containerd.Pausing: + case containerd.Paused: + return fmt.Errorf("cannot start a paused container, try unpause instead") + } + return nil +} diff --git a/pkg/service/container/start_test.go b/pkg/service/container/start_test.go new file mode 100644 index 00000000..e4f51c50 --- /dev/null +++ b/pkg/service/container/start_test.go @@ -0,0 +1,119 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to container start API +var _ = Describe("Container Start API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + con *mocks_container.MockContainer + cid string + tarExtractor *mocks_archive.MockTarExtractor + service container.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + service = NewService(cdClient, mockNerdctlService{ncClient, nil}, logger, nil, nil, tarExtractor) + }) + Context("service", func() { + It("should not return any error", func() { + // set up the mock to return a container that is in running state + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Stopped) + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + //mock the nerdctl client to mock the start container was successful without any error. + ncClient.EXPECT().StartContainer(ctx, con).Return(nil) + logger.EXPECT().Debugf("starting container: %s", cid) + logger.EXPECT().Debugf("successfully started: %s", cid) + //service should not return any error + err := service.Start(ctx, cid) + Expect(err).Should(BeNil()) + }) + It("should return not found error", func() { + // set up the mock to mimic no container found for the provided container id + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{}, nil) + logger.EXPECT().Debugf("no such container: %s", gomock.Any()) + + // service should return NotFound error + err := service.Start(ctx, cid) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return multiple containers found error", func() { + // set up the mock to mimic two containers found for the provided container id + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con, con}, nil) + logger.EXPECT().Debugf("multiple IDs found with provided prefix: %s, total containers found: %d", cid, 2) + + // service should return error + err := service.Start(ctx, cid) + Expect(err).Should(Not(BeNil())) + }) + It("should return not modified error as container is running already", func() { + // set up the mock to return a container that is already in running state + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Running) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + + // service should return not modified error. + err := service.Start(ctx, cid) + Expect(errdefs.IsNotModified(err)).Should(BeTrue()) + }) + It("should return not modified error as container is paused", func() { + // set up the mock to return a container that is not running + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Paused) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + + // service should return not modified error. + err := service.Start(ctx, cid) + Expect(err).Should(Not(BeNil())) + }) + It("should fail due to nerdctl client error", func() { + // set up the mock to mimic an error occurred while starting the container using nerdctl function. + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Created) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + + expectedErr := fmt.Errorf("nerdctl error") + ncClient.EXPECT().StartContainer(ctx, con).Return(expectedErr) + logger.EXPECT().Errorf("Failed to start container: %s. Error: %v", cid, expectedErr) + logger.EXPECT().Debugf("starting container: %s", cid) + + // service should return not modified error. + err := service.Start(ctx, cid) + Expect(err).Should(Equal(expectedErr)) + }) + + }) + +}) diff --git a/pkg/service/container/stats.go b/pkg/service/container/stats.go new file mode 100644 index 00000000..e3f2841e --- /dev/null +++ b/pkg/service/container/stats.go @@ -0,0 +1,281 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + "time" + + v1 "github.com/containerd/cgroups/v3/cgroup1/stats" + v2 "github.com/containerd/cgroups/v3/cgroup2/stats" + "github.com/containerd/containerd" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/typeurl/v2" + dockertypes "github.com/docker/docker/api/types" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +func (s *service) Stats(ctx context.Context, cid string) (<-chan *types.StatsJSON, error) { + con, err := s.getContainer(ctx, cid) + if err != nil { + return nil, err + } + + // get container name + lab, err := con.Labels(ctx) + if err != nil { + return nil, err + } + name := fmt.Sprintf("/%s", lab[labels.Name]) + + // listen to remove event for this container + remove, removeErr := s.client.GetContainerRemoveEvent(ctx, con) + + statsCh := make(chan *types.StatsJSON, 100) + ticker := time.NewTicker(time.Second) + preStats := &types.StatsJSON{} // previous container stats data + + // start a goroutine to collect stats every second + // until either the container is removed or the context is cancelled + go func() { + defer close(statsCh) + defer ticker.Stop() + for { + select { + case <-ticker.C: + statsJSON, err := s.collectContainerStats(ctx, con) + if err != nil { + // log warning and send an empty stats object + s.logger.Warnf("error collecting container %s stats: %s", con.ID(), err) + preStats = &types.StatsJSON{ID: con.ID(), Name: name} + statsCh <- preStats + } else if statsJSON == nil { + // send an empty stats object + preStats = &types.StatsJSON{ID: con.ID(), Name: name} + statsCh <- preStats + } else { + // set current stats properties and update previous stats + statsJSON.Read = time.Now() + statsJSON.PreRead = preStats.Read + statsJSON.PreCPUStats = preStats.CPUStats + statsJSON.ID = con.ID() + statsJSON.Name = name + statsCh <- statsJSON + preStats = statsJSON + } + case err = <-removeErr: + if err != nil { + s.logger.Errorf("container remove event error: %s", err) + } + return + case <-remove: + return + case <-ctx.Done(): + return + } + } + }() + + return statsCh, nil +} + +func (s *service) collectContainerStats(ctx context.Context, con containerd.Container) (*types.StatsJSON, error) { + task, err := con.Task(ctx, nil) + if err != nil { + // if task was not found, it implies the container is not running, but it's not necessarily an error + if cerrdefs.IsNotFound(err) { + return nil, nil + } + return nil, err + } + + // return an empty stats object if the container is not running + taskStatus, err := task.Status(ctx) + if err != nil { + return nil, err + } + status := taskStatus.Status + if status == containerd.Created || + status == containerd.Stopped || + status == containerd.Unknown { + return nil, nil + } + + // get container cgroup metrics + metrics, err := task.Metrics(ctx) + if err != nil { + return nil, err + } + anydata, err := typeurl.UnmarshalAny(metrics.Data) + if err != nil { + return nil, err + } + + var st *types.StatsJSON + switch v := anydata.(type) { + case *v1.Metrics: + st = collectCgroup1Stats(v) + case *v2.Metrics: + st = collectCgroup2Stats(v) + default: + return nil, fmt.Errorf("cannot convert metric data to cgroups.Metrics") + } + + // get total system usage and number of cores + systemUsage, err := s.stats.GetSystemCPUUsage() + if err != nil { + return nil, err + } + onlineCPUs, err := s.stats.GetNumberOnlineCPUs() + if err != nil { + return nil, err + } + st.CPUStats.SystemUsage = systemUsage + st.CPUStats.OnlineCPUs = onlineCPUs + + // get network usage stats + pid := int(task.Pid()) + netNS, err := s.nctlContainerSvc.InspectNetNS(ctx, pid) + if err != nil { + return nil, err + } + networks, err := s.stats.CollectNetworkStats(pid, netNS.Interfaces) + if err != nil { + return nil, err + } + if len(networks) > 0 { + st.Networks = networks + } + + return st, nil +} + +// collectCgroup1Stats uses the cgroup v1 API to infer +// resource usage statistics from the metrics. +// +// Adapted from https://github.com/moby/moby/blob/v24.0.4/daemon/stats_unix.go#L57-L149 +func collectCgroup1Stats(data *v1.Metrics) *types.StatsJSON { + st := types.StatsJSON{} + + if data.Pids != nil { + st.PidsStats = dockertypes.PidsStats{ + Current: data.Pids.Current, + Limit: data.Pids.Limit, + } + } + + if data.CPU != nil && data.CPU.Usage != nil { + st.CPUStats = types.CPUStats{ + CPUUsage: dockertypes.CPUUsage{ + TotalUsage: data.CPU.Usage.Total, + PercpuUsage: data.CPU.Usage.PerCPU, + UsageInKernelmode: data.CPU.Usage.Kernel, + UsageInUsermode: data.CPU.Usage.User, + }, + OnlineCPUs: uint32(len(data.CPU.Usage.PerCPU)), + } + } + + if data.Memory != nil && data.Memory.Usage != nil { + st.MemoryStats = dockertypes.MemoryStats{ + Usage: data.Memory.Usage.Usage, + MaxUsage: data.Memory.Usage.Max, + Failcnt: data.Memory.Usage.Failcnt, + Limit: data.Memory.Usage.Limit, + } + } + + if data.Blkio != nil { + st.BlkioStats = dockertypes.BlkioStats{ + IoServiceBytesRecursive: translateBlkioEntry(data.Blkio.IoServiceBytesRecursive), + IoServicedRecursive: translateBlkioEntry(data.Blkio.IoServicedRecursive), + IoQueuedRecursive: translateBlkioEntry(data.Blkio.IoQueuedRecursive), + IoServiceTimeRecursive: translateBlkioEntry(data.Blkio.IoServiceTimeRecursive), + IoWaitTimeRecursive: translateBlkioEntry(data.Blkio.IoWaitTimeRecursive), + IoMergedRecursive: translateBlkioEntry(data.Blkio.IoMergedRecursive), + IoTimeRecursive: translateBlkioEntry(data.Blkio.IoTimeRecursive), + SectorsRecursive: translateBlkioEntry(data.Blkio.SectorsRecursive), + } + } + + return &st +} + +// collectCgroup2Stats uses the newer cgroup v2 API to infer +// resource usage statistics from the metrics +// +// Adapted from https://github.com/moby/moby/blob/v24.0.4/daemon/stats_unix.go#L151-L251 +func collectCgroup2Stats(data *v2.Metrics) *types.StatsJSON { + st := types.StatsJSON{} + + if data.Pids != nil { + st.PidsStats = dockertypes.PidsStats{ + Current: data.Pids.Current, + Limit: data.Pids.Limit, + } + } + + if data.CPU != nil { + st.CPUStats = types.CPUStats{ + CPUUsage: dockertypes.CPUUsage{ + TotalUsage: data.CPU.UsageUsec * 1000, + // PercpuUsage is not supported + UsageInKernelmode: data.CPU.SystemUsec * 1000, + UsageInUsermode: data.CPU.UserUsec * 1000, + }, + } + } + + if data.Memory != nil { + st.MemoryStats = dockertypes.MemoryStats{ + Usage: data.Memory.Usage, + // MaxUsage is not supported + Limit: data.Memory.UsageLimit, + } + if data.MemoryEvents != nil { + st.MemoryStats.Failcnt = data.MemoryEvents.Oom + } + } + + if data.Io != nil { + var isbr []dockertypes.BlkioStatEntry + for _, re := range data.Io.Usage { + isbr = append(isbr, + dockertypes.BlkioStatEntry{ + Major: re.Major, + Minor: re.Minor, + Op: "read", + Value: re.Rbytes, + }, + dockertypes.BlkioStatEntry{ + Major: re.Major, + Minor: re.Minor, + Op: "write", + Value: re.Wbytes, + }, + ) + } + st.BlkioStats = dockertypes.BlkioStats{ + IoServiceBytesRecursive: isbr, + // Other fields are unsupported + } + } + + return &st +} + +func translateBlkioEntry(entries []*v1.BlkIOEntry) []dockertypes.BlkioStatEntry { + out := make([]dockertypes.BlkioStatEntry, len(entries)) + for i, re := range entries { + out[i] = dockertypes.BlkioStatEntry{ + Major: re.Major, + Minor: re.Minor, + Op: re.Op, + Value: re.Value, + } + } + return out +} diff --git a/pkg/service/container/stats_test.go b/pkg/service/container/stats_test.go new file mode 100644 index 00000000..5ef1853e --- /dev/null +++ b/pkg/service/container/stats_test.go @@ -0,0 +1,455 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + "time" + + v1 "github.com/containerd/cgroups/v3/cgroup1/stats" + v2 "github.com/containerd/cgroups/v3/cgroup2/stats" + "github.com/containerd/containerd" + cTypes "github.com/containerd/containerd/api/types" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/events" + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/typeurl/v2" + dockertypes "github.com/docker/docker/api/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + eventtype "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_statsutil" + "google.golang.org/protobuf/types/known/anypb" +) + +// Unit tests related to container stats API +var _ = Describe("Container Stats API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + stats *mocks_statsutil.MockStatsUtil + con *mocks_container.MockContainer + task *mocks_container.MockTask + cid string + cname string + removeCh chan *events.Envelope + removeErrCh chan error + s service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + stats = mocks_statsutil.NewMockStatsUtil(mockCtrl) + cid = "123" + cname = "/test" + con = mocks_container.NewMockContainer(mockCtrl) + task = mocks_container.NewMockTask(mockCtrl) + removeCh = make(chan *events.Envelope) + removeErrCh = make(chan error) + con.EXPECT().ID().Return(cid).AnyTimes() + con.EXPECT().Labels(gomock.Any()).Return(map[string]string{labels.Name: "test"}, nil).AnyTimes() + cdClient.EXPECT().GetContainerRemoveEvent(gomock.Any(), con).Return(removeCh, removeErrCh).AnyTimes() + s = service{ + client: cdClient, + nctlContainerSvc: mockNerdctlService{ncClient, nil}, + logger: logger, + stats: stats, + } + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + }) + Context("service", func() { + It("should return NotFound error if container was not found", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{}, nil) + + // service should return NotFound error + statsCh, err := s.Stats(ctx, cid) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + Expect(statsCh).Should(BeNil()) + }) + It("should return empty stats objects if task was not found", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(gomock.Any(), nil).Return(nil, cerrdefs.ErrNotFound).MinTimes(1) + + // service should return the stats channel + ctx, cancel := context.WithCancel(ctx) + statsCh, err := s.Stats(ctx, cid) + Expect(err).Should(BeNil()) + + // wait 2 ticks for stats channel to be populated + time.Sleep(time.Second * 2) + cancel() + + // check returnted stats objects + expected := types.StatsJSON{ID: cid, Name: cname} + num := 0 + for st := range statsCh { + Expect(*st).Should(Equal(expected)) + num += 1 + } + // should tick 1 or 2 times in 2 seconds + Expect(num).Should(Or(Equal(1), Equal(2))) + }) + It("should return empty stats objects if there was an error in getting container status", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(gomock.Any(), nil).Return(task, nil).MinTimes(1) + task.EXPECT().Status(gomock.Any()).Return( + containerd.Status{}, fmt.Errorf("error getting status")).MinTimes(1) + logger.EXPECT().Warnf(gomock.Any(), gomock.Any()).MinTimes(1) + + // service should return the stats channel + ctx, cancel := context.WithCancel(ctx) + statsCh, err := s.Stats(ctx, cid) + Expect(err).Should(BeNil()) + + // wait 2 ticks for stats channel to be populated + time.Sleep(time.Second * 2) + cancel() + + // check returned stats objects + expected := types.StatsJSON{ID: cid, Name: cname} + num := 0 + for st := range statsCh { + Expect(*st).Should(Equal(expected)) + num += 1 + } + // should tick 1 or 2 times in 2 seconds + Expect(num).Should(Or(Equal(1), Equal(2))) + }) + It("should return empty stats objects if task metrics are not in the correct format", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(gomock.Any(), nil).Return(task, nil).MinTimes(1) + task.EXPECT().Status(gomock.Any()).Return( + containerd.Status{Status: containerd.Running}, nil).MinTimes(1) + + // define an invalid metrics type + data := eventtype.Event{} + anydata, err := typeurl.MarshalAny(&data) + Expect(err).Should(BeNil()) + metrics := &cTypes.Metric{Data: &anypb.Any{TypeUrl: anydata.GetTypeUrl(), Value: anydata.GetValue()}} + task.EXPECT().Metrics(gomock.Any()).Return(metrics, nil).MinTimes(1) + logger.EXPECT().Warnf(gomock.Any(), gomock.Any()).MinTimes(1) + + // service should return the stats channel + ctx, cancel := context.WithCancel(ctx) + statsCh, err := s.Stats(ctx, cid) + Expect(err).Should(BeNil()) + + // wait 2 ticks for stats channel to be populated + time.Sleep(time.Second * 2) + cancel() + + // check returned stats objects + expected := types.StatsJSON{ID: cid, Name: cname} + num := 0 + for st := range statsCh { + Expect(*st).Should(Equal(expected)) + num += 1 + } + // should tick 1 or 2 times in 2 seconds + Expect(num).Should(Or(Equal(1), Equal(2))) + }) + It("should return empty stats objects if there was an error collecting network stats", func() { + pid := 457 + netNS := native.NetNS{ + Interfaces: []native.NetInterface{}, + } + metrics, _ := getDummyMetricsV1() + + // setup mocks + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(gomock.Any(), nil).Return(task, nil).MinTimes(1) + task.EXPECT().Status(gomock.Any()).Return( + containerd.Status{Status: containerd.Running}, nil).MinTimes(1) + task.EXPECT().Pid().Return(uint32(pid)).MinTimes(1) + ncClient.EXPECT().InspectNetNS(gomock.Any(), pid).Return(&netNS, nil).MinTimes(1) + task.EXPECT().Metrics(gomock.Any()).Return(metrics, nil).MinTimes(1) + stats.EXPECT().GetSystemCPUUsage().Return(uint64(1000), nil).MinTimes(1) + stats.EXPECT().GetNumberOnlineCPUs().Return(uint32(3), nil).MinTimes(1) + + // setup error mock + stats.EXPECT().CollectNetworkStats(pid, netNS.Interfaces).Return( + nil, fmt.Errorf("error collecting network stats")).MinTimes(1) + logger.EXPECT().Warnf(gomock.Any(), gomock.Any()).MinTimes(1) + + // service should return the stats channel + ctx, cancel := context.WithCancel(ctx) + statsCh, err := s.Stats(ctx, cid) + Expect(err).Should(BeNil()) + + // wait 2 ticks for stats channel to be populated + time.Sleep(time.Second * 2) + cancel() + + // check returned stats objects + expected := types.StatsJSON{ID: cid, Name: cname} + num := 0 + for st := range statsCh { + Expect(*st).Should(Equal(expected)) + num += 1 + } + // should tick 1 or 2 times in 2 seconds + Expect(num).Should(Or(Equal(1), Equal(2))) + }) + It("should return empty stats objects for a container that is not running", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(gomock.Any(), nil).Return(task, nil).MinTimes(1) + task.EXPECT().Status(gomock.Any()).Return( + containerd.Status{Status: containerd.Stopped}, nil).MinTimes(1) + + // service should return the stats channel + ctx, cancel := context.WithCancel(ctx) + statsCh, err := s.Stats(ctx, cid) + Expect(err).Should(BeNil()) + + // wait 2 ticks for stats channel to be populated + time.Sleep(time.Second * 2) + cancel() + + // check returned stats objects + expected := types.StatsJSON{ID: cid, Name: cname} + num := 0 + for st := range statsCh { + Expect(*st).Should(Equal(expected)) + num += 1 + } + // should tick 1 or 2 times in 2 seconds + Expect(num).Should(Or(Equal(1), Equal(2))) + }) + It("should stop sending updates after container is removed", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(gomock.Any(), nil).Return(nil, cerrdefs.ErrNotFound).MinTimes(1) + + // service should return the stats channel + statsCh, err := s.Stats(ctx, cid) + Expect(err).Should(BeNil()) + + // wait 2 ticks for stats channel to be populated + // and then send container remove event + time.Sleep(time.Second * 2) + removeCh <- &events.Envelope{} + + // check returned stats objects + expected := types.StatsJSON{ID: cid, Name: cname} + num := 0 + for st := range statsCh { + Expect(*st).Should(Equal(expected)) + num += 1 + } + // should tick 1 or 2 times in 2 seconds + Expect(num).Should(Or(Equal(1), Equal(2))) + }) + It("should stop sending updates and log error after container is removed with error", func() { + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(gomock.Any(), nil).Return(nil, cerrdefs.ErrNotFound).MinTimes(1) + + // service should return the stats channel + statsCh, err := s.Stats(ctx, cid) + Expect(err).Should(BeNil()) + + // wait 2 ticks for stats channel to be populated + // and then send container remove event + time.Sleep(time.Second * 2) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + removeErrCh <- fmt.Errorf("error removing container") + + // check returned stats objects + expected := types.StatsJSON{ID: cid, Name: cname} + num := 0 + for st := range statsCh { + Expect(*st).Should(Equal(expected)) + num += 1 + } + // should tick 1 or 2 times in 2 seconds + Expect(num).Should(Or(Equal(1), Equal(2))) + }) + It("should return expected stats objects from given metrics", func() { + pid := 456 + netNS := native.NetNS{ + Interfaces: []native.NetInterface{}, + } + metrics1, expected1 := getDummyMetricsV1() + metrics2, expected2 := getDummyMetricsV2() + expected := []*types.StatsJSON{expected1, expected2} + + // setup mocks + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(gomock.Any(), nil).Return(task, nil).MinTimes(2) + task.EXPECT().Status(gomock.Any()).Return( + containerd.Status{Status: containerd.Running}, nil).MinTimes(2) + task.EXPECT().Pid().Return(uint32(pid)).MinTimes(2) + ncClient.EXPECT().InspectNetNS(gomock.Any(), pid).Return(&netNS, nil).MinTimes(2) + + // expect calls to Metrics() to return dummy cgroups data + task.EXPECT().Metrics(gomock.Any()).Return(metrics1, nil) + task.EXPECT().Metrics(gomock.Any()).Return(metrics2, nil) + // subsequent calls will return the first metric + task.EXPECT().Metrics(gomock.Any()).Return(metrics1, nil).AnyTimes() + + // mock statsutil + netStats := dockertypes.NetworkStats{ + RxBytes: 20, + TxBytes: 30, + RxPackets: 10, + TxPackets: 5, + RxErrors: 1, + TxErrors: 2, + RxDropped: 5, + TxDropped: 10, + } + stats.EXPECT().GetSystemCPUUsage().Return(uint64(2500), nil).MinTimes(2) + stats.EXPECT().GetNumberOnlineCPUs().Return(uint32(3), nil).MinTimes(2) + stats.EXPECT().CollectNetworkStats(pid, netNS.Interfaces).Return( + map[string]dockertypes.NetworkStats{"eth0": netStats}, nil).MinTimes(2) + + // service should return the stats channel + ctx, cancel := context.WithCancel(ctx) + statsCh, err := s.Stats(ctx, cid) + Expect(err).Should(BeNil()) + + // wait 3 ticks for stats channel to be populated + time.Sleep(time.Second * 3) + cancel() + + // expected stats + for _, exp := range expected { + exp.ID = cid + exp.Name = cname + exp.Networks = map[string]dockertypes.NetworkStats{"eth0": netStats} + } + + // check returned stats objects + num := 0 + prev := &types.StatsJSON{} + for st := range statsCh { + var exp *types.StatsJSON + if num < 2 { + exp = expected[num] + } else { + exp = expected[0] + } + exp.Read = st.Read + exp.PreRead = prev.Read + exp.PreCPUStats = prev.CPUStats + Expect(*st).Should(Equal(*exp)) + prev = exp + num += 1 + } + // should tick 2 or 3 times in 3 seconds + Expect(num).Should(Or(Equal(2), Equal(3))) + }) + }) + +}) + +func getDummyMetricsV1() (*cTypes.Metric, *types.StatsJSON) { + // containerd task metrics + data := v1.Metrics{ + Pids: &v1.PidsStat{Current: 10, Limit: 20}, + CPU: &v1.CPUStat{ + Usage: &v1.CPUUsage{ + Total: 1000, + Kernel: 500, + User: 250, + PerCPU: []uint64{1, 2, 3, 4}, + }, + }, + Memory: &v1.MemoryStat{ + Usage: &v1.MemoryEntry{ + Limit: 1000, + Usage: 250, + Max: 500, + Failcnt: 50, + }, + }, + } + anydata, err := typeurl.MarshalAny(&data) + Expect(err).Should(BeNil()) + m := cTypes.Metric{Data: &anypb.Any{TypeUrl: anydata.GetTypeUrl(), Value: anydata.GetValue()}} + + // expected stats object for dummy metrics + expected := types.StatsJSON{} + expected.PidsStats = dockertypes.PidsStats{Current: 10, Limit: 20} + expected.CPUStats = types.CPUStats{ + CPUUsage: dockertypes.CPUUsage{ + TotalUsage: 1000, + UsageInKernelmode: 500, + UsageInUsermode: 250, + PercpuUsage: []uint64{1, 2, 3, 4}, + }, + SystemUsage: 2500, + OnlineCPUs: 3, + } + expected.MemoryStats = dockertypes.MemoryStats{ + Usage: 250, + Limit: 1000, + MaxUsage: 500, + Failcnt: 50, + } + + return &m, &expected +} + +func getDummyMetricsV2() (*cTypes.Metric, *types.StatsJSON) { + // containerd task metrics + data := v2.Metrics{ + Pids: &v2.PidsStat{Current: 20, Limit: 40}, + CPU: &v2.CPUStat{ + UsageUsec: 10, + UserUsec: 7, + SystemUsec: 20, + }, + Memory: &v2.MemoryStat{ + Usage: 100, + UsageLimit: 500, + }, + MemoryEvents: &v2.MemoryEvents{Oom: 30}, + } + anydata, err := typeurl.MarshalAny(&data) + Expect(err).Should(BeNil()) + m := cTypes.Metric{Data: &anypb.Any{TypeUrl: anydata.GetTypeUrl(), Value: anydata.GetValue()}} + + // expected stats object for dummy metrics + expected := types.StatsJSON{} + expected.PidsStats = dockertypes.PidsStats{Current: 20, Limit: 40} + expected.CPUStats = types.CPUStats{ + CPUUsage: dockertypes.CPUUsage{ + TotalUsage: 10000, + UsageInKernelmode: 20000, + UsageInUsermode: 7000, + }, + SystemUsage: 2500, + OnlineCPUs: 3, + } + expected.MemoryStats = dockertypes.MemoryStats{ + Usage: 100, + Limit: 500, + Failcnt: 30, + } + + return &m, &expected +} diff --git a/pkg/service/container/stop.go b/pkg/service/container/stop.go new file mode 100644 index 00000000..9ebfb009 --- /dev/null +++ b/pkg/service/container/stop.go @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + "time" + + "github.com/containerd/containerd" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// Stop function stops a running container. It returns nil when it successfully stops the container. +func (s *service) Stop(ctx context.Context, cid string, timeout *time.Duration) error { + con, err := s.getContainer(ctx, cid) + if err != nil { + return err + } + + if s.isContainerStopped(ctx, con) { + return errdefs.NewNotModified(fmt.Errorf("container is already stopped: %s", cid)) + } + if err = s.nctlContainerSvc.StopContainer(ctx, con, timeout); err != nil { + s.logger.Errorf("Failed to stop container: %s. Error: %v", cid, err) + return err + } + s.logger.Debugf("successfully stopped: %s", cid) + return nil +} + +// isContainerStopped returns true when container is not in running state. +func (s *service) isContainerStopped(ctx context.Context, con containerd.Container) bool { + status := s.client.GetContainerStatus(ctx, con) + if status == containerd.Stopped || status == containerd.Created { + return true + } + return false +} diff --git a/pkg/service/container/stop_test.go b/pkg/service/container/stop_test.go new file mode 100644 index 00000000..f1fb33b7 --- /dev/null +++ b/pkg/service/container/stop_test.go @@ -0,0 +1,117 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/container" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_archive" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to container stop API +var _ = Describe("Container Stop API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlContainerSvc + con *mocks_container.MockContainer + cid string + tarExtractor *mocks_archive.MockTarExtractor + service container.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlContainerSvc(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + tarExtractor = mocks_archive.NewMockTarExtractor(mockCtrl) + + service = NewService(cdClient, mockNerdctlService{ncClient, nil}, logger, nil, nil, tarExtractor) + }) + Context("service", func() { + It("should not return any error", func() { + // set up the mock to return a container that is in running state + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Running) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + //mock the nerdctl client to mock the stop container was successful without any error. + ncClient.EXPECT().StopContainer(ctx, con, gomock.Any()) + logger.EXPECT().Debugf("successfully stopped: %s", cid) + //service should not return any error + err := service.Stop(ctx, cid, nil) + Expect(err).Should(BeNil()) + }) + It("should return not found error", func() { + // set up the mock to mimic no container found for the provided container id + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{}, nil) + logger.EXPECT().Debugf("no such container: %s", cid) + + // service should return NotFound error + err := service.Stop(ctx, cid, nil) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return multiple containers found error", func() { + // set up the mock to mimic two containers found for the provided container id + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con, con}, nil) + logger.EXPECT().Debugf("multiple IDs found with provided prefix: %s, total containers found: %d", cid, 2) + + // service should return error + err := service.Stop(ctx, cid, nil) + Expect(err).Should(Not(BeNil())) + }) + It("should return not modified error as container is stopped already", func() { + // set up the mock to return a container that is already in stopped state + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Stopped) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + + // service should return not modified error. + err := service.Stop(ctx, cid, nil) + Expect(errdefs.IsNotModified(err)).Should(BeTrue()) + }) + It("should return not modified error as container is not running", func() { + // set up the mock to return a container that is not running + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Created) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + + // service should return not modified error. + err := service.Stop(ctx, cid, nil) + Expect(errdefs.IsNotModified(err)).Should(BeTrue()) + }) + It("should fail due to nerdctl client error", func() { + // set up the mock to mimic an error occurred while stopping the container using nerdctl function + cdClient.EXPECT().GetContainerStatus(gomock.Any(), gomock.Any()).Return(containerd.Running) + cdClient.EXPECT().SearchContainer(gomock.Any(), gomock.Any()).Return( + []containerd.Container{con}, nil) + + expectedErr := fmt.Errorf("nerdctl error") + ncClient.EXPECT().StopContainer(ctx, con, gomock.Any()).Return(expectedErr) + logger.EXPECT().Errorf("Failed to stop container: %s. Error: %v", cid, expectedErr) + + // service should return not modified error. + err := service.Stop(ctx, cid, nil) + Expect(err).Should(Equal(expectedErr)) + }) + }) + +}) diff --git a/pkg/service/container/wait.go b/pkg/service/container/wait.go new file mode 100644 index 00000000..abf622ed --- /dev/null +++ b/pkg/service/container/wait.go @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "context" + + "github.com/containerd/containerd" +) + +func (s *service) Wait(ctx context.Context, cid string, condition string) (code int64, err error) { + con, err := s.getContainer(ctx, cid) + // container wait status code is uint32, use -1 to indicate container search error + if err != nil { + return -1, err + } + s.logger.Debugf("wait container: %s", con.ID()) + rawcode, err := waitContainer(ctx, con) + return int64(rawcode), err +} + +// TODO: contribute to nerdctl to make this function public +func waitContainer(ctx context.Context, container containerd.Container) (code uint32, err error) { + task, err := container.Task(ctx, nil) + if err != nil { + return 0, err + } + + statusC, err := task.Wait(ctx) + if err != nil { + return 0, err + } + + status := <-statusC + code, _, err = status.Result() + + return code, err +} diff --git a/pkg/service/exec/exec.go b/pkg/service/exec/exec.go new file mode 100644 index 00000000..77b9d94a --- /dev/null +++ b/pkg/service/exec/exec.go @@ -0,0 +1,93 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package exec + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/cio" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/runfinch/finch-daemon/pkg/api/handlers/exec" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +type service struct { + client backend.ContainerdClient + logger flog.Logger +} + +// NewService creates a new service to run exec processes +func NewService( + client backend.ContainerdClient, + logger flog.Logger, +) exec.Service { + return &service{ + client: client, + logger: logger, + } +} + +type execInstance struct { + Container containerd.Container + Task containerd.Task + Process containerd.Process +} + +func (s *service) getContainer(ctx context.Context, cid string) (containerd.Container, error) { + searchResult, err := s.client.SearchContainer(ctx, cid) + if err != nil { + s.logger.Errorf("failed to search container: %s. error: %v", cid, err) + return nil, err + } + matchCount := len(searchResult) + + // if container not found then return NotFound error. + if matchCount == 0 { + s.logger.Debugf("no such container: %s", cid) + return nil, errdefs.NewNotFound(fmt.Errorf("no such container: %s", cid)) + } + // if more than one container found with the provided id return error. + if matchCount > 1 { + s.logger.Debugf("multiple IDs found with provided prefix: %s, total containers found: %d", cid, matchCount) + return nil, fmt.Errorf("multiple IDs found with provided prefix: %s", cid) + } + + return searchResult[0], nil +} + +func (s *service) loadExecInstance(ctx context.Context, conID, execID string, attach cio.Attach) (*execInstance, error) { + con, err := s.getContainer(ctx, conID) + if err != nil { + if cerrdefs.IsNotFound(err) || errdefs.IsNotFound(err) { + return nil, errdefs.NewNotFound(fmt.Errorf("container not found: %v", err)) + } + return nil, err + } + + task, err := con.Task(ctx, nil) + if err != nil { + if cerrdefs.IsNotFound(err) { + return nil, errdefs.NewNotFound(fmt.Errorf("task not found: %v", err)) + } + return nil, err + } + + proc, err := task.LoadProcess(ctx, execID, attach) + if err != nil { + if cerrdefs.IsNotFound(err) { + return nil, errdefs.NewNotFound(fmt.Errorf("process not found: %v", err)) + } + return nil, err + } + + return &execInstance{ + Container: con, + Task: task, + Process: proc, + }, nil +} diff --git a/pkg/service/exec/exec_test.go b/pkg/service/exec/exec_test.go new file mode 100644 index 00000000..a28e05b6 --- /dev/null +++ b/pkg/service/exec/exec_test.go @@ -0,0 +1,192 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package exec + +import ( + "context" + "errors" + "testing" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/cio" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// TestExecService is the entry point of exec service package's unit tests using ginkgo +func TestExecService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Exec APIs Service") +} + +var _ = Describe("Exec API service common ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + con *mocks_container.MockContainer + task *mocks_container.MockTask + proc *mocks_container.MockProcess + cid string + execId string + s service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + cid = "123" + execId = "exec-123" + con = mocks_container.NewMockContainer(mockCtrl) + task = mocks_container.NewMockTask(mockCtrl) + proc = mocks_container.NewMockProcess(mockCtrl) + con.EXPECT().ID().Return(cid).AnyTimes() + s = service{ + client: cdClient, + logger: logger, + } + }) + Context("getContainer", func() { + It("should return the container object if it was found", func() { + // search method returns one container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con}, nil) + + result, err := s.getContainer(ctx, cid) + Expect(result).Should(Equal(con)) + Expect(err).Should(BeNil()) + }) + It("should return an error if search container method fails", func() { + // search method returns no container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + nil, errors.New("search container error")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + + result, err := s.getContainer(ctx, cid) + Expect(result).Should(BeNil()) + Expect(err).Should(Not(BeNil())) + }) + It("should return NotFound error if no container was found", func() { + // search method returns no container + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + result, err := s.getContainer(ctx, cid) + Expect(result).Should(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return an error if multiple containers were found", func() { + // search method returns two containers + cdClient.EXPECT().SearchContainer(gomock.Any(), cid).Return( + []containerd.Container{con, con}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + result, err := s.getContainer(ctx, cid) + Expect(result).Should(BeNil()) + Expect(err).Should(Not(BeNil())) + }) + }) + Context("loadExecInstance", func() { + It("should return the exec instance if found", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, execId, nil).Return(proc, nil) + + Expect(s.loadExecInstance(ctx, cid, execId, nil)).Should(Equal(&execInstance{ + Container: con, + Task: task, + Process: proc, + })) + }) + It("should use the provided attach to attach to the process", func() { + attach := cio.NewAttach() + + cdClient.EXPECT().SearchContainer(ctx, cid).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + // we can't check equality because cio.Attach is a function type, which can't be compared. non-nil should + // be sufficient, though, as the function either uses the provided attach or nil + task.EXPECT().LoadProcess(ctx, execId, gomock.Not(nil)).Return(proc, nil) + + Expect(s.loadExecInstance(ctx, cid, execId, attach)).Should(Equal(&execInstance{ + Container: con, + Task: task, + Process: proc, + })) + }) + It("should return a NotFound error if the container is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return( + []containerd.Container{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + result, err := s.loadExecInstance(ctx, cid, execId, nil) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(HavePrefix("container not found:")) + Expect(result).Should(BeNil()) + }) + It("should pass through other errors from getContainer", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return( + []containerd.Container{}, errors.New("getContainer error")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any(), gomock.Any()) + + result, err := s.loadExecInstance(ctx, cid, execId, nil) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("getContainer error")) + Expect(result).Should(BeNil()) + }) + It("should return a NotFound error if the task is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(nil, cerrdefs.ErrNotFound) + + result, err := s.loadExecInstance(ctx, cid, execId, nil) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(HavePrefix("task not found:")) + Expect(result).Should(BeNil()) + }) + It("should pass through other errors from con.Task", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(nil, errors.New("task error")) + + result, err := s.loadExecInstance(ctx, cid, execId, nil) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("task error")) + Expect(result).Should(BeNil()) + }) + It("should return a NotFound error if the process is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, execId, nil).Return(nil, cerrdefs.ErrNotFound) + + result, err := s.loadExecInstance(ctx, cid, execId, nil) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(HavePrefix("process not found:")) + Expect(result).Should(BeNil()) + }) + It("should pass through other errors from task.Process", func() { + cdClient.EXPECT().SearchContainer(ctx, cid).Return( + []containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, execId, nil).Return(nil, errors.New("process error")) + + result, err := s.loadExecInstance(ctx, cid, execId, nil) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("process error")) + Expect(result).Should(BeNil()) + }) + }) +}) diff --git a/pkg/service/exec/inspect.go b/pkg/service/exec/inspect.go new file mode 100644 index 00000000..7f8a17b5 --- /dev/null +++ b/pkg/service/exec/inspect.go @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package exec + +import ( + "context" + + "github.com/containerd/containerd" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +func (s *service) Inspect(ctx context.Context, conID string, execId string) (*types.ExecInspect, error) { + exec, err := s.loadExecInstance(ctx, conID, execId, nil) + if err != nil { + return nil, err + } + + var running bool + var exitCode int + status, err := exec.Process.Status(ctx) + if err != nil { + s.logger.Warnf("error getting process status for proc %s: %v", exec.Process.ID(), err) + running = false + exitCode = 0 + } else { + running = status.Status == containerd.Running + exitCode = int(status.ExitStatus) + } + + inspectResult := &types.ExecInspect{ + ID: exec.Process.ID(), + Running: running, + ExitCode: &exitCode, + ProcessConfig: &types.ExecProcessConfig{}, + CanRemove: running, + ContainerID: exec.Container.ID(), + DetachKeys: []byte(""), + Pid: int(exec.Process.Pid()), + } + + if exec.Process.IO() != nil { + inspectResult.ProcessConfig.Tty = exec.Process.IO().Config().Terminal + inspectResult.OpenStdin = exec.Process.IO().Config().Stdin != "" + inspectResult.OpenStdout = exec.Process.IO().Config().Stdout != "" + inspectResult.OpenStderr = exec.Process.IO().Config().Stderr != "" + } + + return inspectResult, nil +} diff --git a/pkg/service/exec/inspect_test.go b/pkg/service/exec/inspect_test.go new file mode 100644 index 00000000..3c2d15af --- /dev/null +++ b/pkg/service/exec/inspect_test.go @@ -0,0 +1,141 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package exec + +import ( + "context" + "errors" + "time" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/cio" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/exec" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_cio" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Exec Inspect API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + con *mocks_container.MockContainer + task *mocks_container.MockTask + proc *mocks_container.MockProcess + procIO *mocks_cio.MockIO + s exec.Service + ) + BeforeEach(func() { + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + task = mocks_container.NewMockTask(mockCtrl) + proc = mocks_container.NewMockProcess(mockCtrl) + procIO = mocks_cio.NewMockIO(mockCtrl) + s = NewService(cdClient, logger) + }) + Context("service", func() { + It("should not return an error on success", func() { + now := time.Now() + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + proc.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Running, + ExitStatus: 0, + ExitTime: now, + }, nil) + proc.EXPECT().ID().Return("exec-123") + con.EXPECT().ID().Return("123") + proc.EXPECT().Pid().Return(uint32(123)) + proc.EXPECT().IO().Times(5).Return(procIO) + procIO.EXPECT().Config().Times(4).Return(cio.Config{ + Terminal: false, + Stdin: "", + Stdout: "", + Stderr: "", + }) + + exitCode := 0 + Expect(s.Inspect(ctx, "123", "exec-123")).Should(Equal(&types.ExecInspect{ + ID: "exec-123", + Running: true, + ExitCode: &exitCode, + ProcessConfig: &types.ExecProcessConfig{ + Tty: false, + }, + OpenStdin: false, + OpenStderr: false, + OpenStdout: false, + CanRemove: true, + ContainerID: "123", + DetachKeys: []byte(""), + Pid: 123, + })) + }) + It("should return a NotFound error if loadExecInstance returns NotFound", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return(nil, cerrdefs.ErrNotFound) + logger.EXPECT().Errorf("failed to search container: %s. error: %v", "123", gomock.Any()) + + inspectResult, err := s.Inspect(ctx, "123", "exec-123") + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + Expect(inspectResult).Should(BeNil()) + }) + It("should pass through non-NotFound errors from loadExecInstance", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return(nil, errors.New("getContainer error")) + logger.EXPECT().Errorf("failed to search container: %s. error: %v", "123", gomock.Any()) + + inspectResult, err := s.Inspect(ctx, "123", "exec-123") + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("getContainer error")) + Expect(inspectResult).Should(BeNil()) + }) + It("should log a warning if proc.Status returns an error", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + proc.EXPECT().Status(ctx).Return(containerd.Status{}, errors.New("status error")) + proc.EXPECT().ID().Return("exec-123").Times(2) + logger.EXPECT().Warnf("error getting process status for proc %s: %v", "exec-123", gomock.Any()) + con.EXPECT().ID().Return("123") + proc.EXPECT().Pid().Return(uint32(123)) + proc.EXPECT().IO().Times(5).Return(procIO) + procIO.EXPECT().Config().Times(4).Return(cio.Config{ + Terminal: false, + Stdin: "", + Stdout: "", + Stderr: "", + }) + + exitCode := 0 + Expect(s.Inspect(ctx, "123", "exec-123")).Should(Equal(&types.ExecInspect{ + ID: "exec-123", + Running: false, + ExitCode: &exitCode, + ProcessConfig: &types.ExecProcessConfig{ + Tty: false, + }, + OpenStdin: false, + OpenStderr: false, + OpenStdout: false, + CanRemove: false, + ContainerID: "123", + DetachKeys: []byte(""), + Pid: 123, + })) + }) + }) +}) diff --git a/pkg/service/exec/resize.go b/pkg/service/exec/resize.go new file mode 100644 index 00000000..eba996b2 --- /dev/null +++ b/pkg/service/exec/resize.go @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package exec + +import ( + "context" + + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +func (s *service) Resize(ctx context.Context, options *types.ExecResizeOptions) error { + exec, err := s.loadExecInstance(ctx, options.ConID, options.ExecID, nil) + if err != nil { + return err + } + + return exec.Process.Resize(ctx, uint32(options.Width), uint32(options.Height)) +} diff --git a/pkg/service/exec/resize_test.go b/pkg/service/exec/resize_test.go new file mode 100644 index 00000000..41de543a --- /dev/null +++ b/pkg/service/exec/resize_test.go @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package exec + +import ( + "context" + "errors" + + "github.com/containerd/containerd" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/exec" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Exec Resize API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + con *mocks_container.MockContainer + task *mocks_container.MockTask + proc *mocks_container.MockProcess + s exec.Service + ) + BeforeEach(func() { + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + task = mocks_container.NewMockTask(mockCtrl) + proc = mocks_container.NewMockProcess(mockCtrl) + s = NewService(cdClient, logger) + }) + Context("service", func() { + It("should not return an error on success", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + proc.EXPECT().Resize(ctx, uint32(321), uint32(123)).Return(nil) + + err := s.Resize(ctx, &types.ExecResizeOptions{ + ConID: "123", + ExecID: "exec-123", + Height: 123, + Width: 321, + }) + Expect(err).Should(BeNil()) + }) + It("should return a not found error if the exec instance is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return(nil, cerrdefs.ErrNotFound) + logger.EXPECT().Errorf("failed to search container: %s. error: %v", "123", gomock.Any()) + + err := s.Resize(ctx, &types.ExecResizeOptions{ + ConID: "123", + ExecID: "exec-123", + Height: 123, + Width: 321, + }) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should pass through any errors from resize", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + proc.EXPECT().Resize(ctx, uint32(321), uint32(123)).Return(errors.New("resize error")) + + err := s.Resize(ctx, &types.ExecResizeOptions{ + ConID: "123", + ExecID: "exec-123", + Height: 123, + Width: 321, + }) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("resize error")) + }) + }) +}) diff --git a/pkg/service/exec/start.go b/pkg/service/exec/start.go new file mode 100644 index 00000000..aafc5fcd --- /dev/null +++ b/pkg/service/exec/start.go @@ -0,0 +1,117 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package exec + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + "syscall" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/cio" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/nerdctl/pkg/signalutil" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// StdinCloser is from https://github.com/containerd/containerd/blob/v1.4.3/cmd/ctr/commands/tasks/exec.go#L181-L194 +type StdinCloser struct { + mu sync.Mutex + Stdin io.ReadCloser + Closer func() + closed bool +} + +func (s *StdinCloser) Read(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return 0, syscall.EBADF + } + n, err := s.Stdin.Read(p) + if err != nil { + if s.Closer != nil { + s.Closer() + s.closed = true + } + } + return n, err +} + +func (s *service) Start(ctx context.Context, options *types.ExecStartOptions) error { + var attach cio.Attach + var in io.Reader + var stdinC = &StdinCloser{ + Stdin: options.Stdin, + } + if !options.Detach { + in = stdinC + attach = cio.NewAttach(cio.WithStreams(in, options.Stdout, options.Stderr)) + } + exec, err := s.loadExecInstance(ctx, options.ConID, options.ExecID, attach) + if err != nil { + switch { + case strings.HasPrefix(err.Error(), "task not found"): + return errdefs.NewConflict(fmt.Errorf("container %s is not running", options.ConID)) + default: + return err + } + } + + taskStatus, err := exec.Task.Status(ctx) + if err != nil { + if cerrdefs.IsNotFound(err) { + return errdefs.NewConflict(fmt.Errorf("container %s is not running", options.ConID)) + } + return err + } + if taskStatus.Status != containerd.Running { + return errdefs.NewConflict(fmt.Errorf("container %s is not running", options.ConID)) + } + + stdinC.Closer = func() { + exec.Process.CloseIO(ctx, containerd.WithStdinCloser) + } + + statusC, err := exec.Process.Wait(ctx) + if err != nil { + return err + } + + if !options.Detach { + if options.Tty && options.ConsoleSize != nil { + if err = exec.Process.Resize(ctx, uint32(options.ConsoleSize[1]), uint32(options.ConsoleSize[0])); err != nil { + s.logger.Errorf("could not resize console: %v", err) + } + } + sigc := signalutil.ForwardAllSignals(ctx, exec.Process) + defer signalutil.StopCatch(sigc) + } + + if options.SuccessResponse != nil { + options.SuccessResponse() + } + + if err = exec.Process.Start(ctx); err != nil { + return err + } + if options.Detach { + return nil + } + + status := <-statusC + code, _, err := status.Result() + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("exec failed with exit code %d", code) + } + + return nil +} diff --git a/pkg/service/exec/start_test.go b/pkg/service/exec/start_test.go new file mode 100644 index 00000000..ab50a571 --- /dev/null +++ b/pkg/service/exec/start_test.go @@ -0,0 +1,294 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package exec + +import ( + "bytes" + "context" + "errors" + "io" + "time" + + "github.com/containerd/containerd" + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/exec" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_container" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Exec Start API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + con *mocks_container.MockContainer + task *mocks_container.MockTask + proc *mocks_container.MockProcess + rw io.ReadWriteCloser + success bool + successResp func() + statusC chan containerd.ExitStatus + s exec.Service + startOpts *types.ExecStartOptions + ) + BeforeEach(func() { + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + con = mocks_container.NewMockContainer(mockCtrl) + task = mocks_container.NewMockTask(mockCtrl) + proc = mocks_container.NewMockProcess(mockCtrl) + s = NewService(cdClient, logger) + rw = &CloseableBuffer{ + bytes.NewBuffer([]byte{}), + } + + success = false + successResp = func() { + success = true + } + statusC = make(chan containerd.ExitStatus) + }) + Context("service", func() { + Context("detach", func() { + BeforeEach(func() { + startOpts = &types.ExecStartOptions{ + ExecStartCheck: &types.ExecStartCheck{ + Detach: true, + Tty: false, + ConsoleSize: &[2]uint{123, 321}, + }, + ConID: "123", + ExecID: "exec-123", + Stdin: rw, + Stdout: rw, + Stderr: rw, + SuccessResponse: successResp, + } + }) + It("should not return error on success", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Running, + }, nil) + proc.EXPECT().Wait(ctx).Return(statusC, nil) + proc.EXPECT().Start(ctx).Return(nil) + + err := s.Start(ctx, startOpts) + Expect(err).Should(BeNil()) + Expect(success).Should(BeTrue()) + }) + It("should return a NotFound error if the container is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return(nil, cerrdefs.ErrNotFound) + logger.EXPECT().Errorf("failed to search container: %s. error: %v", "123", gomock.Any()) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return a Conflict error if the task is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(nil, cerrdefs.ErrNotFound) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + }) + It("should return a Conflict error if the task status cannot be found", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{}, cerrdefs.ErrNotFound) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + }) + It("should return a Conflict error if the task status is not running", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Stopped, + }, nil) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + }) + It("should return a NotFound error if the process is not found", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(nil, cerrdefs.ErrNotFound) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should pass through errors from loadExecInstance", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{}, errors.New("getContainer error")) + logger.EXPECT().Errorf("failed to search container: %s. error: %v", "123", gomock.Any()) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeFalse()) + Expect(err.Error()).Should(Equal("getContainer error")) + }) + It("should pass through errors from task.Status", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{}, errors.New("status error")) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("status error")) + }) + It("should pass through errors from proc.Wait", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Running, + }, nil) + proc.EXPECT().Wait(ctx).Return(nil, errors.New("wait error")) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("wait error")) + }) + It("should pass through errors from proc.Start", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", nil).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Running, + }, nil) + proc.EXPECT().Wait(ctx).Return(statusC, nil) + proc.EXPECT().Start(ctx).Return(errors.New("start error")) + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("start error")) + }) + }) + Context("attach", func() { + var now time.Time + BeforeEach(func() { + startOpts = &types.ExecStartOptions{ + ExecStartCheck: &types.ExecStartCheck{ + Detach: false, + Tty: true, + ConsoleSize: &[2]uint{123, 321}, + }, + ConID: "123", + ExecID: "exec-123", + Stdin: rw, + Stdout: rw, + Stderr: rw, + SuccessResponse: successResp, + } + now = time.Now() + }) + It("should not throw any errors on success", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", gomock.Any()).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Running, + }, nil) + proc.EXPECT().Wait(ctx).Return(statusC, nil) + proc.EXPECT().Resize(ctx, uint32(321), uint32(123)).Return(nil) + proc.EXPECT().Start(ctx).Return(nil) + + go func() { + statusC <- *containerd.NewExitStatus(uint32(0), now, nil) + }() + + err := s.Start(ctx, startOpts) + Expect(err).Should(BeNil()) + Expect(success).Should(BeTrue()) + }) + It("should log errors from proc.Resize", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", gomock.Any()).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Running, + }, nil) + proc.EXPECT().Wait(ctx).Return(statusC, nil) + proc.EXPECT().Resize(ctx, uint32(321), uint32(123)).Return(errors.New("resize error")) + logger.EXPECT().Errorf("could not resize console: %v", errors.New("resize error")) + proc.EXPECT().Start(ctx).Return(nil) + + go func() { + statusC <- *containerd.NewExitStatus(uint32(0), now, nil) + }() + + err := s.Start(ctx, startOpts) + Expect(err).Should(BeNil()) + Expect(success).Should(BeTrue()) + }) + It("should return errors from the process", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", gomock.Any()).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Running, + }, nil) + proc.EXPECT().Wait(ctx).Return(statusC, nil) + proc.EXPECT().Resize(ctx, uint32(321), uint32(123)).Return(nil) + proc.EXPECT().Start(ctx).Return(nil) + + go func() { + statusC <- *containerd.NewExitStatus(uint32(0), now, errors.New("process error")) + }() + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("process error")) + Expect(success).Should(BeTrue()) + }) + It("should throw an error on a non-zero exit code", func() { + cdClient.EXPECT().SearchContainer(ctx, "123").Return([]containerd.Container{con}, nil) + con.EXPECT().Task(ctx, nil).Return(task, nil) + task.EXPECT().LoadProcess(ctx, "exec-123", gomock.Any()).Return(proc, nil) + task.EXPECT().Status(ctx).Return(containerd.Status{ + Status: containerd.Running, + }, nil) + proc.EXPECT().Wait(ctx).Return(statusC, nil) + proc.EXPECT().Resize(ctx, uint32(321), uint32(123)).Return(nil) + proc.EXPECT().Start(ctx).Return(nil) + + go func() { + statusC <- *containerd.NewExitStatus(uint32(1), now, nil) + }() + + err := s.Start(ctx, startOpts) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("exec failed with exit code 1")) + Expect(success).Should(BeTrue()) + }) + }) + }) +}) + +type CloseableBuffer struct { + *bytes.Buffer +} + +func (buf *CloseableBuffer) Close() error { + // NOP + return nil +} diff --git a/pkg/service/image/image.go b/pkg/service/image/image.go new file mode 100644 index 00000000..51b7a281 --- /dev/null +++ b/pkg/service/image/image.go @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "fmt" + "strings" + + "github.com/containerd/containerd/images" + "github.com/containerd/containerd/reference/docker" + "github.com/runfinch/finch-daemon/pkg/api/handlers/image" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +// setting getAuthCredsFunc as a variable to allow mocking this function for unit testing +var getAuthCredsFunc = (*service).getAuthCreds + +type service struct { + client backend.ContainerdClient + nctlImageSvc backend.NerdctlImageSvc + logger flog.Logger +} + +func (s *service) getImage(ctx context.Context, name string) (*images.Image, error) { + images, err := s.client.SearchImage(ctx, name) + if err != nil { + s.logger.Errorf("failed to search image: %s. error: %s", name, err) + return nil, err + } + matchCount := len(images) + + // if image not found then return NotFound error + if matchCount == 0 { + s.logger.Debugf("no such image: %s", name) + return nil, errdefs.NewNotFound(fmt.Errorf("no such image: %s", name)) + } + + // if multiple images are found, check that their digests are all the same, otherwise there could be a conflict + if matchCount > 1 { + var observedDigest string + for _, img := range images { + if observedDigest == "" { + observedDigest = img.Target.Digest.String() + continue + } + + if observedDigest != img.Target.Digest.String() { + s.logger.Debugf("multiple images with different digests found for %s", name) + return nil, fmt.Errorf("multiple images with different digests found for %s", name) + } + } + } + + // if one or more images are found with the same digest return the first one + return &images[0], nil +} + +func NewService(client backend.ContainerdClient, nerdctlImageSvc backend.NerdctlImageSvc, logger flog.Logger) image.Service { + return &service{ + client: client, + nctlImageSvc: nerdctlImageSvc, + logger: logger, + } +} + +const ( + defaultTag = "latest" + tagDigestPrefix = "sha256:" + eventType = "image" +) + +func canonicalize(name, tag string) (string, error) { + if name != "" { + if strings.HasPrefix(tag, tagDigestPrefix) { + name += "@" + tag + } else if tag != "" { + name += ":" + tag + } + } else { + name = tag + } + ref, err := docker.ParseAnyReference(name) + if err != nil { + return "", err + } + if named, ok := ref.(docker.Named); ok && refNeedsTag(ref) { + tagged, err := docker.WithTag(named, defaultTag) + if err == nil { + ref = tagged + } + } + return ref.String(), nil +} + +func refNeedsTag(ref docker.Reference) bool { + _, tagged := ref.(docker.Tagged) + _, digested := ref.(docker.Digested) + return !(tagged || digested) +} diff --git a/pkg/service/image/image_test.go b/pkg/service/image/image_test.go new file mode 100644 index 00000000..a11665ab --- /dev/null +++ b/pkg/service/image/image_test.go @@ -0,0 +1,176 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "errors" + "testing" + + "github.com/containerd/containerd/images" + "github.com/containerd/containerd/remotes" + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + v1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// TestImageHandler function is the entry point of image service package's unit test using ginkgo +func TestImageService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Image APIs Service") +} + +var _ = Describe("Image API service common ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlImageSvc + name string + name2 string + digest1 digest.Digest + digest2 digest.Digest + img images.Image + img2 images.Image + img3 images.Image + s service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlImageSvc(mockCtrl) + name = "test-image" + name2 = "test-image-2" + digest1 = digest.NewDigestFromBytes(digest.SHA256, []byte("123abc")) + digest2 = digest.NewDigestFromBytes(digest.SHA256, []byte("abc123")) + img = images.Image{ + Name: name, + Target: v1.Descriptor{ + Digest: digest1, + }, + } + img2 = images.Image{ + Name: name2, + Target: v1.Descriptor{ + Digest: digest1, + }, + } + img3 = images.Image{ + Name: name2, + Target: v1.Descriptor{ + Digest: digest2, + }, + } + s = service{ + client: cdClient, + nctlImageSvc: ncClient, + logger: logger, + } + }) + Context("getImage", func() { + It("should return the containerd image if it was found", func() { + // search method returns one image + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + []images.Image{img}, nil) + + result, err := s.getImage(ctx, name) + Expect(*result).Should(Equal(img)) + Expect(err).Should(BeNil()) + }) + It("should return an error if search image method fails", func() { + // search method returns an error + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + nil, errors.New("search image error")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + + result, err := s.getImage(ctx, name) + Expect(result).Should(BeNil()) + Expect(err).Should(Not(BeNil())) + }) + It("should return NotFound error if no image was found", func() { + // search method returns no image + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + []images.Image{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + result, err := s.getImage(ctx, name) + Expect(result).Should(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return the first image if multiple images with the same digest were found", func() { + // search method returns two images + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + []images.Image{img, img2}, nil) + + result, err := s.getImage(ctx, name) + Expect(err).Should(BeNil()) + Expect(*result).Should(Equal(img)) + }) + It("should return an error if multiple images with different digests were found", func() { + // search method returns two images with different digests + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + []images.Image{img, img3}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + result, err := s.getImage(ctx, name) + Expect(err).ShouldNot(BeNil()) + Expect(result).Should(BeNil()) + }) + }) +}) + +// expectGetAuthCreds creates a new mocked object for getAuthCreds function +// with expected input parameters. +func expectGetAuthCreds(ctrl *gomock.Controller, refDomain string, ac dockertypes.AuthConfig) *mockGetAuthCreds { + return &mockGetAuthCreds{ + expectedDomain: refDomain, + expectedAuth: ac, + ctrl: ctrl, + } +} + +type mockGetAuthCreds struct { + expectedDomain string + expectedAuth dockertypes.AuthConfig + ctrl *gomock.Controller +} + +// Return mocks getAuthCreds function with expected input parameters and returns the passed output values. +func (m *mockGetAuthCreds) Return(creds dockerconfigresolver.AuthCreds, err error) { + m.ctrl.RecordCall(m, "GetAuthCreds", m.expectedDomain, m.expectedAuth) + getAuthCredsFunc = func(_ *service, domain string, ac dockertypes.AuthConfig) (dockerconfigresolver.AuthCreds, error) { + m.GetAuthCreds(domain, ac) + return creds, err + } +} +func (m *mockGetAuthCreds) GetAuthCreds(domain string, ac dockertypes.AuthConfig) { + m.ctrl.Call(m, "GetAuthCreds", domain, ac) +} + +// dummy remotes resolver +type mockResolver struct{} + +func (m *mockResolver) Resolve(context.Context, string) (string, ocispec.Descriptor, error) { + return "", ocispec.Descriptor{}, nil +} + +func (m *mockResolver) Fetcher(context.Context, string) (remotes.Fetcher, error) { + return nil, nil +} + +func (m *mockResolver) Pusher(context.Context, string) (remotes.Pusher, error) { + return nil, nil +} diff --git a/pkg/service/image/inspect.go b/pkg/service/image/inspect.go new file mode 100644 index 00000000..0ad90604 --- /dev/null +++ b/pkg/service/image/inspect.go @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" +) + +func (s *service) Inspect(ctx context.Context, name string) (*dockercompat.Image, error) { + img, err := s.getImage(ctx, name) + if err != nil { + return nil, err + } + + image, err := s.nctlImageSvc.InspectImage(ctx, *img) + if err != nil { + return nil, err + } + + // reset image Id so that it matches image digest (nerdctl compatible) instead of docker-compatible id + image.ID = img.Target.Digest.String() + return image, nil +} diff --git a/pkg/service/image/inspect_test.go b/pkg/service/image/inspect_test.go new file mode 100644 index 00000000..0613cd3c --- /dev/null +++ b/pkg/service/image/inspect_test.go @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "errors" + + "github.com/containerd/containerd/images" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/image" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to image inspect API +var _ = Describe("Image Inspect API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlImageSvc + name string + img images.Image + inspect dockercompat.Image + service image.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlImageSvc(mockCtrl) + name = "test-image" + img = images.Image{Name: name} + inspect = dockercompat.Image{ + ID: name, + RepoTags: []string{"test-image:latest"}, + RepoDigests: []string{"test-image@test-digest"}, + Size: 100, + } + + service = NewService(cdClient, ncClient, logger) + }) + Context("service", func() { + It("should return the inspect object upon success", func() { + // search image method returns one image + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + []images.Image{img}, nil) + + ncClient.EXPECT().InspectImage(gomock.Any(), img).Return( + &inspect, nil) + + // service should return inspect object + result, err := service.Inspect(ctx, name) + Expect(*result).Should(Equal(inspect)) + Expect(err).Should(BeNil()) + }) + It("should return NotFound error if image was not found", func() { + // search image method returns no image + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + []images.Image{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // service should return a NotFound error + result, err := service.Inspect(ctx, name) + Expect(result).Should(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should succeed if multiple images were found for the given Id", func() { + // search image method returns multiple images + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + []images.Image{img, img}, nil) + + ncClient.EXPECT().InspectImage(gomock.Any(), img).Return( + &inspect, nil) + + // service should return an error + result, err := service.Inspect(ctx, name) + Expect(err).Should(BeNil()) + Expect(*result).Should(Equal(inspect)) + }) + It("should return an error if search image method failed", func() { + // search image method returns no image + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + nil, errors.New("error message")) + logger.EXPECT().Errorf(gomock.Any(), gomock.Any()) + + // service should return an error + result, err := service.Inspect(ctx, name) + Expect(result).Should(BeNil()) + Expect(err).ShouldNot(BeNil()) + }) + It("should return an error if the backend inspect method failed", func() { + // search image method returns one image + cdClient.EXPECT().SearchImage(gomock.Any(), name).Return( + []images.Image{img}, nil) + + ncClient.EXPECT().InspectImage(gomock.Any(), img).Return( + nil, errors.New("error message")) + + // service should return an error + result, err := service.Inspect(ctx, name) + Expect(result).Should(BeNil()) + Expect(err).ShouldNot(BeNil()) + }) + }) +}) diff --git a/pkg/service/image/list.go b/pkg/service/image/list.go new file mode 100644 index 00000000..90f7accd --- /dev/null +++ b/pkg/service/image/list.go @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "fmt" + "log" + + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +func (s *service) List(ctx context.Context) ([]types.ImageSummary, error) { + imgs, err := s.client.GetClient().ListImages(context.Background()) + if err != nil { + return nil, fmt.Errorf("list: failed to list images: %w", err) + } + summaries := []types.ImageSummary{} + // TODO post-process to dedup and populate RepoTags/RepoDigests + // TODO walk image.Target() with the content store to get manifest digests? + for _, img := range imgs { + log.Println(img) + if ok, err := img.IsUnpacked(ctx, "overlayfs"); !ok { + continue + } else if err != nil { + return nil, err + } + info, err := img.ContentStore().Info(ctx, img.Target().Digest) + if err != nil { + return nil, err + } + size, err := img.Size(ctx) + if err != nil { + return nil, err + } + summaries = append(summaries, types.ImageSummary{ + ID: string(img.Target().Digest), + RepoTags: []string{ + img.Name(), + }, + Created: info.CreatedAt.Unix(), + Size: size, + }) + } + return summaries, nil +} diff --git a/pkg/service/image/pull.go b/pkg/service/image/pull.go new file mode 100644 index 00000000..4512b8c0 --- /dev/null +++ b/pkg/service/image/pull.go @@ -0,0 +1,146 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/remotes/docker" + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + dockertypes "github.com/docker/cli/cli/config/types" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (s *service) Pull(ctx context.Context, name, tag, platformStr string, ac *dockertypes.AuthConfig, outStream io.Writer) error { + // get host platform's default spec if unspecified + var platform ocispec.Platform + var err error + if platformStr == "" { + platform = s.client.DefaultPlatformSpec() + } else { + platform, err = s.client.ParsePlatform(platformStr) + } + if err != nil { + return fmt.Errorf("invalid platform %s: %s", platformStr, err) + } + + // parse image reference into registry hostname and image name + rawRef := toImageRef(name, tag) + ref, refDomain, err := s.client.ParseDockerRef(rawRef) + if err != nil { + return errdefs.NewInvalidFormat(err) + } + + // get auth creds and the corresponding docker remotes resolver + var creds dockerconfigresolver.AuthCreds + if ac != nil { + creds, err = getAuthCredsFunc(s, refDomain, *ac) + if err != nil { + return err + } + } + resolver, _, err := s.nctlImageSvc.GetDockerResolver(ctx, refDomain, creds) + if err != nil { + return fmt.Errorf("failed to initialize remotes resolver: %s", err) + } + + // finally, pull the image + _, err = s.nctlImageSvc.PullImage( + ctx, + outStream, outStream, + resolver, + ref, + []ocispec.Platform{platform}, + ) + + if err != nil { + if errors.Is(err, docker.ErrInvalidAuthorization) || cerrdefs.IsNotFound(err) { + err = errdefs.NewNotFound(err) + } + + // nerdctl issue: if there is an error during pull, it is returned before the + // progress writer is shutdown properly. This can cause panic as the progress writer + // tries to write to the http stream writer, which is nil after the handler returns. + // Wait 100ms to give progress writer enough time to exit. + // + // TODO: Fix upstream. https://github.com/containerd/nerdctl/blob/v1.4.0/pkg/imgutil/pull/pull.go#L95-L101 + time.Sleep(time.Millisecond * 100) + } + return err +} + +func toImageRef(name, tag string) string { + if tag == "" { + return name + } + // Handle the case where the tag starts with a digest algorithm. We do not + // handle digests specified without an algorithm. + if strings.HasPrefix(tag, "sha256:") { + return fmt.Sprintf("%s@%s", name, tag) + } + return fmt.Sprintf("%s:%s", name, tag) +} + +// getAuthCreds returns authentication credentials resolver function from image reference domain and auth config +func (s *service) getAuthCreds(refDomain string, ac dockertypes.AuthConfig) (dockerconfigresolver.AuthCreds, error) { + // return nil if no credentials specified + if ac.Username == "" && ac.Password == "" && ac.IdentityToken == "" && ac.RegistryToken == "" { + return nil, nil + } + + // domain expected by the authcreds function + // DefaultHost converts "docker.io" to "registry-1.docker.io" + expectedDomain, err := s.client.DefaultDockerHost(refDomain) + if err != nil { + return nil, err + } + + // ensure that server address matches the image reference domain + sa := ac.ServerAddress + if sa != "" { + saHostname := convertToHostname(sa) + // "registry-1.docker.io" can show up as "https://index.docker.io/v1/" in ServerAddress + if expectedDomain == "registry-1.docker.io" { + if saHostname != refDomain && sa != dockerconfigresolver.IndexServer { + return nil, fmt.Errorf("specified server address %s does not match the image reference domain %s", sa, refDomain) + } + } else if saHostname != refDomain { + return nil, fmt.Errorf("specified server address %s does not match the image reference domain %s", sa, refDomain) + } + } + + // return auth creds function + return func(domain string) (string, string, error) { + if domain != expectedDomain { + return "", "", fmt.Errorf("expected domain %s, but got %s", expectedDomain, domain) + } + if ac.IdentityToken != "" { + return "", ac.IdentityToken, nil + } else { + return ac.Username, ac.Password, nil + } + }, nil +} + +// convertToHostname converts a registry url which has http|https prepended +// to just an hostname. +// Copied from github.com/docker/docker/registry.ConvertToHostname to reduce dependencies. +func convertToHostname(url string) string { + stripped := url + if strings.HasPrefix(url, "http://") { + stripped = strings.TrimPrefix(url, "http://") + } else if strings.HasPrefix(url, "https://") { + stripped = strings.TrimPrefix(url, "https://") + } + + hostName, _, _ := strings.Cut(stripped, "/") + return hostName +} diff --git a/pkg/service/image/pull_test.go b/pkg/service/image/pull_test.go new file mode 100644 index 00000000..e2e4913f --- /dev/null +++ b/pkg/service/image/pull_test.go @@ -0,0 +1,262 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "fmt" + + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/remotes" + "github.com/containerd/containerd/remotes/docker" + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to image pull API +var _ = Describe("Image Pull API ", func() { + Context("service", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlImageSvc + name string + tag string + platform string + imageRef string + domain string + ociPlatform ocispec.Platform + authCfg dockertypes.AuthConfig + authCreds dockerconfigresolver.AuthCreds + resolver remotes.Resolver + s service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlImageSvc(mockCtrl) + name = "public.ecr.aws/test-image/test-image" + tag = "test-tag" + imageRef = fmt.Sprintf("%s:%s", name, tag) + domain = "public.ecr.aws" + platform = "linux/amd64" + ociPlatform = ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + } + authCfg = dockertypes.AuthConfig{ + Username: "test-user", + Password: "test-password", + } + authCreds = func(_ string) (string, string, error) { + return authCfg.Username, authCfg.Password, nil + } + resolver = &mockResolver{} + + s = service{ + client: cdClient, + nctlImageSvc: ncClient, + logger: logger, + } + }) + + It("should return no errors upon success", func() { + // expected backend calls + cdClient.EXPECT().ParsePlatform(platform).Return( + ociPlatform, nil, + ) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + resolver, nil, nil, + ) + ncClient.EXPECT().PullImage(gomock.Any(), nil, nil, resolver, imageRef, []ocispec.Platform{ociPlatform}).Return( + nil, nil, + ) + + // service should return no error + err := s.Pull(ctx, name, tag, platform, &authCfg, nil) + Expect(err).ShouldNot(HaveOccurred()) + }) + It("should return no errors when reference spec includes a digest spec", func() { + tag := "sha256:7ea94d4e7f346a9328a9ff053ab149e3c99c1737f8d251094e7cc38664c3d4b9" + imageRef := fmt.Sprintf("%s@%s", name, tag) + + // expected backend calls + cdClient.EXPECT().ParsePlatform(platform).Return( + ociPlatform, nil, + ) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + resolver, nil, nil, + ) + ncClient.EXPECT().PullImage(gomock.Any(), nil, nil, resolver, imageRef, []ocispec.Platform{ociPlatform}).Return( + nil, nil, + ) + + // service should return no error + err := s.Pull(ctx, name, tag, platform, &authCfg, nil) + Expect(err).ShouldNot(HaveOccurred()) + }) + It("should use default platform if not specified", func() { + // expected backend calls + cdClient.EXPECT().DefaultPlatformSpec().Return(ociPlatform) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + resolver, nil, nil, + ) + ncClient.EXPECT().PullImage(gomock.Any(), nil, nil, resolver, imageRef, []ocispec.Platform{ociPlatform}).Return( + nil, nil, + ) + + // service should return no error + err := s.Pull(ctx, name, tag, "", &authCfg, nil) + Expect(err).ShouldNot(HaveOccurred()) + }) + It("should succeed without authentication", func() { + // expected backend calls + cdClient.EXPECT().DefaultPlatformSpec().Return(ociPlatform) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Nil()).Return( + resolver, nil, nil, + ) + ncClient.EXPECT().PullImage(gomock.Any(), nil, nil, resolver, imageRef, []ocispec.Platform{ociPlatform}).Return( + nil, nil, + ) + + // service should return no error + err := s.Pull(ctx, name, tag, "", nil, nil) + Expect(err).ShouldNot(HaveOccurred()) + }) + It("should return an error if platform is invalid", func() { + // expected backend calls + cdClient.EXPECT().ParsePlatform(platform).Return( + ocispec.Platform{}, fmt.Errorf("invalid platform"), + ) + + // service should return invalid platform error + err := s.Pull(ctx, name, tag, platform, nil, nil) + Expect(err).Should(HaveOccurred()) + }) + It("should return an error if image reference is invalid", func() { + // expected backend calls + cdClient.EXPECT().DefaultPlatformSpec().Return(ociPlatform) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + "", "", fmt.Errorf("invalid image reference"), + ) + + // service should return invalid reference error + err := s.Pull(ctx, name, tag, "", nil, nil) + Expect(err).Should(HaveOccurred()) + }) + It("should return an error if credentials are invalid", func() { + // expected backend calls + cdClient.EXPECT().DefaultPlatformSpec().Return(ociPlatform) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + nil, fmt.Errorf("invalid credentials"), + ) + + // service should return invalid credentials error + err := s.Pull(ctx, name, tag, "", &authCfg, nil) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("invalid credentials")) + }) + It("should fail due to resolver error", func() { + // expected backend calls + cdClient.EXPECT().DefaultPlatformSpec().Return(ociPlatform) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Nil()).Return( + nil, nil, fmt.Errorf("resolver error"), + ) + + // service should return resolver error + err := s.Pull(ctx, name, tag, "", nil, nil) + Expect(err).Should(HaveOccurred()) + }) + It("should return an error upon service failure", func() { + // expected backend calls + cdClient.EXPECT().DefaultPlatformSpec().Return(ociPlatform) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Nil()).Return( + resolver, nil, nil, + ) + ncClient.EXPECT().PullImage(gomock.Any(), nil, nil, resolver, imageRef, []ocispec.Platform{ociPlatform}).Return( + nil, fmt.Errorf("service error"), + ) + + // service should return service error + err := s.Pull(ctx, name, tag, "", nil, nil) + Expect(err).Should(HaveOccurred()) + }) + It("should return a not found error if authorization failed", func() { + // expected backend calls + cdClient.EXPECT().DefaultPlatformSpec().Return(ociPlatform) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Nil()).Return( + resolver, nil, nil, + ) + ncClient.EXPECT().PullImage(gomock.Any(), nil, nil, resolver, imageRef, []ocispec.Platform{ociPlatform}).Return( + nil, docker.ErrInvalidAuthorization, + ) + + // service should return not found error + err := s.Pull(ctx, name, tag, "", nil, nil) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return a not found error if image cannot be resolved", func() { + // expected backend calls + cdClient.EXPECT().DefaultPlatformSpec().Return(ociPlatform) + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Nil()).Return( + resolver, nil, nil, + ) + ncClient.EXPECT().PullImage(gomock.Any(), nil, nil, resolver, imageRef, []ocispec.Platform{ociPlatform}).Return( + nil, cerrdefs.ErrNotFound, + ) + + // service should return not found error + err := s.Pull(ctx, name, tag, "", nil, nil) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + }) +}) diff --git a/pkg/service/image/push.go b/pkg/service/image/push.go new file mode 100644 index 00000000..53a0c04d --- /dev/null +++ b/pkg/service/image/push.go @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/containerd/containerd/images/converter" + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (s *service) Push(ctx context.Context, name, tag string, ac *dockertypes.AuthConfig, outStream io.Writer) (*types.PushResult, error) { + // Canonicalize and parse raw image reference as "image:tag" or "image@digest" + rawRef, err := canonicalize(name, tag) + if err != nil { + return nil, errdefs.NewInvalidFormat(fmt.Errorf("failed to canonicalize the ref: %w", err)) + } + ref, refDomain, err := s.client.ParseDockerRef(rawRef) + if err != nil { + return nil, errdefs.NewInvalidFormat(err) + } + + // Create a reduced platform image locally to avoid "400 Bad request" for multi-platform manifests + // https://github.com/containerd/nerdctl/blob/v1.7.2/pkg/cmd/image/push.go#L93-L111 + platMC := s.client.DefaultPlatformStrict() + pushRef := ref + "-tmp-reduced-platform" + platImg, err := s.client.ConvertImage(ctx, pushRef, ref, converter.WithPlatform(platMC)) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return nil, errdefs.NewNotFound(err) + } + return nil, fmt.Errorf("failed to create a tmp single-platform image %q: %s", pushRef, err) + } + if platImg != nil { + defer s.client.DeleteImage(ctx, platImg.Name) + } + s.logger.Debugf("pushing as a reduced-platform image (%s, %s)", platImg.Target.MediaType, platImg.Target.Digest) + + // Get auth creds and the corresponding docker remotes resolver + var creds dockerconfigresolver.AuthCreds + if ac != nil { + creds, err = getAuthCredsFunc(s, refDomain, *ac) + if err != nil { + return nil, err + } + } + resolver, tracker, err := s.nctlImageSvc.GetDockerResolver(ctx, refDomain, creds) + if err != nil { + return nil, fmt.Errorf("failed to initialize remotes resolver: %s", err) + } + + // finally, push the image + if err = s.nctlImageSvc.PushImage( + ctx, + resolver, + tracker, + outStream, + pushRef, ref, + platMC, + ); err != nil { + return nil, err + } + + // send aux information for the pushed image + img, err := s.client.GetImage(ctx, ref) + if err != nil { + return nil, nil + } + size, err := img.Size(ctx) + if err != nil { + return nil, nil + } + return &types.PushResult{ + Tag: tag, + Digest: img.Target().Digest.String(), + Size: int(size), + }, nil +} diff --git a/pkg/service/image/push_test.go b/pkg/service/image/push_test.go new file mode 100644 index 00000000..8576adf3 --- /dev/null +++ b/pkg/service/image/push_test.go @@ -0,0 +1,301 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/content" + "github.com/containerd/containerd/images" + "github.com/containerd/containerd/platforms" + "github.com/containerd/containerd/remotes" + "github.com/containerd/containerd/remotes/docker" + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/runfinch/finch-daemon/pkg/api/handlers/image" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to image push API +var _ = Describe("Image Push API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlImageSvc + name string + tag string + digest string + domain string + rawRef string + pushRef string + authCfg dockertypes.AuthConfig + authCreds dockerconfigresolver.AuthCreds + resolver remotes.Resolver + tracker docker.StatusTracker + service image.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlImageSvc(mockCtrl) + name = "public.ecr.aws/test-image/test-image" + digest = "test-digest" + tag = "test-tag" + domain = "public.ecr.aws" + rawRef = fmt.Sprintf("%s:%s", name, tag) + pushRef = fmt.Sprintf("%s-tmp-reduced-platform", name) + authCfg = dockertypes.AuthConfig{ + Username: "test-user", + Password: "test-password", + } + authCreds = func(_ string) (string, string, error) { + return authCfg.Username, authCfg.Password, nil + } + resolver = &mockResolver{} + tracker = docker.NewInMemoryTracker() + + service = NewService(cdClient, ncClient, logger) + + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + }) + Context("service", func() { + It("should return no errors upon success", func() { + pushImage := &images.Image{Name: pushRef} + image := &mockImage{ + ImageName: name, + ImageDigest: digest, + ImageTag: tag, + ImageSize: 256, + } + expected := types.PushResult{ + Tag: tag, + Digest: digest, + Size: 256, + } + + // expected backend calls + cdClient.EXPECT().ParseDockerRef(rawRef). + Return(name, domain, nil) + cdClient.EXPECT().DefaultPlatformStrict(). + Return(nil) + cdClient.EXPECT().ConvertImage(gomock.Any(), pushImage.Name, name, gomock.Any()). + Return(pushImage, nil) + cdClient.EXPECT().DeleteImage(gomock.Any(), pushImage.Name). + Return(nil) + expectGetAuthCreds(mockCtrl, domain, authCfg). + Return(authCreds, nil) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())). + Return(resolver, tracker, nil) + ncClient.EXPECT().PushImage(gomock.Any(), resolver, tracker, nil, pushImage.Name, name, nil). + Return(nil) + cdClient.EXPECT().GetImage(gomock.Any(), name). + Return(image, nil) + + // service should return no error + result, err := service.Push(ctx, name, tag, &authCfg, nil) + Expect(err).ShouldNot(HaveOccurred()) + Expect(*result).Should(Equal(expected)) + }) + It("should return error due to malformed name", func() { + // service should return error + result, err := service.Push(ctx, "malformed:/image:name", "malformed:tag", &authCfg, nil) + Expect(err).Should(HaveOccurred()) + Expect(result).Should(BeNil()) + }) + It("should return an error if image reference is invalid", func() { + expectedError := fmt.Errorf("invalid image reference") + + // expected backend calls + cdClient.EXPECT().ParseDockerRef(rawRef). + Return("", "", fmt.Errorf("invalid image reference")) + + // service should return invalid reference error + result, err := service.Push(ctx, name, tag, &authCfg, nil) + Expect(err.Error()).Should(ContainSubstring(expectedError.Error())) + Expect(result).Should(BeNil()) + }) + It("should return errors due to image conversion", func() { + expectedError := fmt.Errorf("convert image failed") + + // expected backend calls + cdClient.EXPECT().ParseDockerRef(rawRef). + Return(name, domain, nil) + cdClient.EXPECT().DefaultPlatformStrict(). + Return(nil) + cdClient.EXPECT().ConvertImage(gomock.Any(), pushRef, name, gomock.Any()). + Return(nil, expectedError) + + // service should return error + result, err := service.Push(ctx, name, tag, &authCfg, nil) + Expect(err.Error()).Should(ContainSubstring(expectedError.Error())) + Expect(result).Should(BeNil()) + }) + It("should return a not found errors if image does not exist", func() { + expectedError := fmt.Errorf("image `%s`: not found", name) + + // expected backend calls + cdClient.EXPECT().ParseDockerRef(rawRef). + Return(name, domain, nil) + cdClient.EXPECT().DefaultPlatformStrict(). + Return(nil) + cdClient.EXPECT().ConvertImage(gomock.Any(), pushRef, name, gomock.Any()). + Return(nil, expectedError) + + // service should return error + result, err := service.Push(ctx, name, tag, &authCfg, nil) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + Expect(result).Should(BeNil()) + }) + It("should return an error if credentials are invalid", func() { + pushImage := &images.Image{Name: pushRef} + expectedError := fmt.Errorf("invalid credentials") + + // expected backend calls + cdClient.EXPECT().ParseDockerRef(rawRef). + Return(name, domain, nil) + cdClient.EXPECT().DefaultPlatformStrict(). + Return(nil) + cdClient.EXPECT().ConvertImage(gomock.Any(), pushRef, name, gomock.Any()). + Return(pushImage, nil) + cdClient.EXPECT().DeleteImage(gomock.Any(), pushImage.Name). + Return(nil) + expectGetAuthCreds(mockCtrl, domain, authCfg). + Return(nil, expectedError) + + // service should return error + result, err := service.Push(ctx, name, tag, &authCfg, nil) + Expect(err.Error()).Should(ContainSubstring(expectedError.Error())) + Expect(result).Should(BeNil()) + }) + It("should fail due to resolver error", func() { + pushImage := &images.Image{Name: pushRef} + expectedError := fmt.Errorf("resolver error") + + // expected backend calls + cdClient.EXPECT().ParseDockerRef(rawRef). + Return(name, domain, nil) + cdClient.EXPECT().DefaultPlatformStrict(). + Return(nil) + cdClient.EXPECT().ConvertImage(gomock.Any(), pushRef, name, gomock.Any()). + Return(pushImage, nil) + cdClient.EXPECT().DeleteImage(gomock.Any(), pushImage.Name). + Return(nil) + expectGetAuthCreds(mockCtrl, domain, authCfg). + Return(authCreds, nil) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())). + Return(nil, nil, expectedError) + + // service should return error + result, err := service.Push(ctx, name, tag, &authCfg, nil) + Expect(err.Error()).Should(ContainSubstring(expectedError.Error())) + Expect(result).Should(BeNil()) + }) + It("should return an error upon service failure", func() { + pushImage := &images.Image{Name: pushRef} + expectedError := fmt.Errorf("failed to push image") + + // expected backend calls + cdClient.EXPECT().ParseDockerRef(rawRef). + Return(name, domain, nil) + cdClient.EXPECT().DefaultPlatformStrict(). + Return(nil) + cdClient.EXPECT().ConvertImage(gomock.Any(), pushRef, name, gomock.Any()). + Return(pushImage, nil) + cdClient.EXPECT().DeleteImage(gomock.Any(), pushImage.Name). + Return(nil) + expectGetAuthCreds(mockCtrl, domain, authCfg). + Return(authCreds, nil) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())). + Return(resolver, tracker, nil) + ncClient.EXPECT().PushImage(gomock.Any(), resolver, tracker, nil, pushImage.Name, name, nil). + Return(expectedError) + + // service should return error + result, err := service.Push(ctx, name, tag, &authCfg, nil) + Expect(err.Error()).Should(ContainSubstring(expectedError.Error())) + Expect(result).Should(BeNil()) + }) + }) + + //TODO: need to add an authenticated push unit test. + +}) + +// dummy containerd image +type mockImage struct { + ImageName string + ImageDigest string + ImageTag string + ImageSize int64 +} + +func (m *mockImage) Name() string { + return m.ImageName +} + +func (m *mockImage) Target() ocispec.Descriptor { + return ocispec.Descriptor{Digest: digest.Digest(m.ImageDigest), Size: m.ImageSize} +} + +func (m *mockImage) Labels() map[string]string { + return nil +} + +func (m *mockImage) Unpack(context.Context, string, ...containerd.UnpackOpt) error { + return nil +} + +func (m *mockImage) RootFS(context.Context) ([]digest.Digest, error) { + return nil, nil +} + +func (m *mockImage) Size(context.Context) (int64, error) { + return m.ImageSize, nil +} + +func (m *mockImage) Usage(context.Context, ...containerd.UsageOpt) (int64, error) { + return 0, nil +} + +func (m *mockImage) Config(context.Context) (ocispec.Descriptor, error) { + return ocispec.Descriptor{Digest: digest.Digest(m.ImageDigest), Size: m.ImageSize}, nil +} + +func (m *mockImage) IsUnpacked(context.Context, string) (bool, error) { + return false, nil +} + +func (m *mockImage) ContentStore() content.Store { + return nil +} + +func (m *mockImage) Metadata() images.Image { + return images.Image{ + Name: m.ImageName, + Target: ocispec.Descriptor{Digest: digest.Digest(m.ImageDigest), Size: m.ImageSize}, + } +} + +func (m *mockImage) Platform() platforms.MatchComparer { + return nil +} + +func (m *mockImage) Spec(context.Context) (ocispec.Image, error) { + return ocispec.Image{}, nil +} diff --git a/pkg/service/image/remove.go b/pkg/service/image/remove.go new file mode 100644 index 00000000..75d5a0f1 --- /dev/null +++ b/pkg/service/image/remove.go @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "fmt" + + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (s *service) Remove(ctx context.Context, name string, force bool) (deleted, untagged []string, err error) { + matchCount, uniqueCount, imgs, err := s.nctlImageSvc.SearchImage(ctx, name) + if err != nil { + return + } + if matchCount == 0 { + err = errdefs.NewNotFound(fmt.Errorf("no such image: %s", name)) + return + } + if matchCount > 1 && !(force && uniqueCount == 1) { + err = errdefs.NewConflict(fmt.Errorf( + "unable to delete %s (must be forced) - image is referenced in multiple repositories", name)) + return + } + // check if the image can be deleted + stoppedImgs, runningImgs, err := s.client.GetUsedImages(ctx) + if err != nil { + return nil, nil, err + } + for _, img := range imgs { + if cid, ok := runningImgs[img.Name]; ok { + err = fmt.Errorf("unable to delete %s (cannot be forced) - image is being used by running container %s", name, cid) + return nil, nil, errdefs.NewConflict(err) + } + if cid, ok := stoppedImgs[img.Name]; ok && !force { + err = fmt.Errorf("unable to delete %s (must be forced) - image is being used by stopped container %s", name, cid) + return nil, nil, errdefs.NewConflict(err) + } + } + + //delete image + deleted = []string{} + untagged = []string{} + for _, img := range imgs { + digests, err := s.client.GetImageDigests(ctx, img) + if err != nil { + s.logger.Warnf("Failed to enumerate rootfs. Error: %s", err) + } + err = s.client.DeleteImage(ctx, img.Name) + if err != nil { + return nil, nil, err + } + + // TODO: a digest only gets deleted when all the images ref is deleted. Need to fix this later. + // Nerdctl also has the same problem. it also reports digest got deleted even there are other images + // reference to that digest. + for _, d := range digests { + deleted = append(deleted, d.String()) + } + untagged = append(untagged, fmt.Sprintf("%s:%s", img.Name, img.Target.Digest)) + } + return untagged, deleted, err +} diff --git a/pkg/service/image/remove_test.go b/pkg/service/image/remove_test.go new file mode 100644 index 00000000..cda6069e --- /dev/null +++ b/pkg/service/image/remove_test.go @@ -0,0 +1,147 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + + "github.com/containerd/containerd/images" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/runfinch/finch-daemon/pkg/api/handlers/image" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +// Unit tests related to image remove API +var _ = Describe("Image Remove API", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlImageSvc + name string + img images.Image + service image.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlImageSvc(mockCtrl) + name = "test-image" + img = images.Image{ + Name: name, + Target: ocispec.Descriptor{ + Digest: "test-digest", + }, + } + service = NewService(cdClient, ncClient, logger) + }) + Context("service", func() { + It("should successfully remove the image", func() { + // search image method returns one image + ncClient.EXPECT().SearchImage(gomock.Any(), name).Return( + 1, 1, []*images.Image{&img}, nil) + //setup mock to mimic no image is being used by any container + cdClient.EXPECT().GetUsedImages(gomock.Any()).Return( + make(map[string]string), + make(map[string]string), + nil) + cdClient.EXPECT().DeleteImage(gomock.Any(), gomock.Any()).Return(nil) + cdClient.EXPECT().GetImageDigests(gomock.Any(), gomock.Any()).Return([]digest.Digest{"test-digest"}, nil) + + // service should return inspect object + untagged, deleted, err := service.Remove(ctx, name, false) + Expect(err).Should(BeNil()) + Expect(untagged).Should(HaveLen(1)) + Expect(untagged).Should(ContainElement("test-image:test-digest")) + Expect(deleted).Should(HaveLen(1)) + Expect(deleted).Should(ContainElement("test-digest")) + + }) + It("should return NotFound error if image was not found", func() { + // search image method returns no image + ncClient.EXPECT().SearchImage(gomock.Any(), name).Return( + 0, 0, []*images.Image{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + // service should return a NotFound error + untagged, deleted, err := service.Remove(ctx, name, false) + Expect(untagged).Should(HaveLen(0)) + Expect(deleted).Should(HaveLen(0)) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return an error if multiple images were found for the given Id", func() { + // search image method returns multiple images + ncClient.EXPECT().SearchImage(gomock.Any(), name).Return( + 2, 1, []*images.Image{&img, &img}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + + // service should return an error + untagged, deleted, err := service.Remove(ctx, name, false) + Expect(untagged).Should(HaveLen(0)) + Expect(deleted).Should(HaveLen(0)) + Expect(err).Should(HaveOccurred()) + }) + It("should return an error image is being used by a running container", func() { + // search image method returns one image + ncClient.EXPECT().SearchImage(gomock.Any(), name).Return(1, 1, []*images.Image{&img}, nil) + //setup mock to mimic no image is being used by any container + cdClient.EXPECT().GetUsedImages(gomock.Any()).Return( + make(map[string]string), + map[string]string{"test-image": "test-running-container"}, + nil) + + // service should return inspect object + untagged, deleted, err := service.Remove(ctx, name, false) + Expect(err).Should(Not(BeNil())) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + Expect(untagged).Should(HaveLen(0)) + Expect(deleted).Should(HaveLen(0)) + }) + It("should return an error image is being used by a stopped container", func() { + // search image method returns one image + ncClient.EXPECT().SearchImage(gomock.Any(), name).Return(1, 1, []*images.Image{&img}, nil) + //setup mock to mimic no image is being used by any container + cdClient.EXPECT().GetUsedImages(gomock.Any()).Return( + map[string]string{"test-image": "test-stopped-container"}, + make(map[string]string), + nil) + + // service should return inspect object + untagged, deleted, err := service.Remove(ctx, name, false) + Expect(err).Should(HaveOccurred()) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + Expect(untagged).Should(HaveLen(0)) + Expect(deleted).Should(HaveLen(0)) + }) + It("should successfully remove the image used by stopped container with force flag", func() { + // search image method returns one image + ncClient.EXPECT().SearchImage(gomock.Any(), name).Return(1, 1, []*images.Image{&img}, nil) + //setup mock to mimic no image is being used by any container + cdClient.EXPECT().GetUsedImages(gomock.Any()).Return( + map[string]string{"test-image": "test-stopped-container"}, + make(map[string]string), + nil) + cdClient.EXPECT().DeleteImage(gomock.Any(), gomock.Any()).Return(nil) + cdClient.EXPECT().GetImageDigests(gomock.Any(), gomock.Any()).Return([]digest.Digest{"test-digest"}, nil) + + // service should return inspect object + untagged, deleted, err := service.Remove(ctx, name, true) + Expect(err).Should(BeNil()) + Expect(untagged).Should(HaveLen(1)) + Expect(untagged).Should(ContainElement("test-image:test-digest")) + Expect(deleted).Should(HaveLen(1)) + Expect(deleted).Should(ContainElement("test-digest")) + }) + }) +}) diff --git a/pkg/service/image/tag.go b/pkg/service/image/tag.go new file mode 100644 index 00000000..ce5b77c6 --- /dev/null +++ b/pkg/service/image/tag.go @@ -0,0 +1,95 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "context" + "fmt" + + cerrdefs "github.com/containerd/containerd/errdefs" + "github.com/containerd/nerdctl/pkg/idutil/imagewalker" + "github.com/containerd/nerdctl/pkg/referenceutil" + eventtype "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +const tagEventAction = "tag" + +func (s *service) Tag(ctx context.Context, srcImg string, repo, tag string) error { + imgStore := s.client.GetClient().ImageService() + srcImgName, err := s.getFullImageName(ctx, srcImg) + if err != nil { + return err + } + image, err := imgStore.Get(ctx, srcImgName) + if err != nil { + return err + } + rawRef := fmt.Sprintf("%s:%s", repo, tag) + target, err := referenceutil.ParseDockerRef(rawRef) + if err != nil { + return fmt.Errorf("target parse error: %w", err) + } + image.Name = target.String() + if _, err = imgStore.Create(ctx, image); err != nil { + if cerrdefs.IsAlreadyExists(err) { + if err = imgStore.Delete(ctx, image.Name); err != nil { + return err + } + if _, err = imgStore.Create(ctx, image); err != nil { + return err + } + } else { + return err + } + } + + err = s.client.PublishEvent(ctx, tagTopic(), getTagEvent(image.Target.Digest.String(), rawRef)) + if err != nil { + return err + } + + return nil +} + +func (s *service) getFullImageName(ctx context.Context, name string) (string, error) { + var srcName string + imgWalker := &imagewalker.ImageWalker{ + Client: s.client.GetClient(), + OnFound: func(ctx context.Context, found imagewalker.Found) error { + if srcName == "" { + srcName = found.Image.Name + } + return nil + }, + } + matchCount, err := imgWalker.Walk(ctx, name) + if err != nil { + return "", fmt.Errorf("err from image walker: %w", err) + } + + if matchCount < 1 { + return "", errdefs.NewNotFound(fmt.Errorf("no such image: %s", name)) + } + return srcName, nil +} + +func tagTopic() string { + return fmt.Sprintf("/%s/%s/%s", eventtype.CompatibleTopicPrefix, eventType, tagEventAction) +} + +func getTagEvent(digest, imgName string) *eventtype.Event { + return &eventtype.Event{ + ID: digest, + Status: tagEventAction, + Type: "image", + Action: tagEventAction, + Actor: eventtype.EventActor{ + Id: digest, + Attributes: map[string]string{ + "name": imgName, + }, + }, + } +} diff --git a/pkg/service/network/connect.go b/pkg/service/network/connect.go new file mode 100644 index 00000000..54d88f0d --- /dev/null +++ b/pkg/service/network/connect.go @@ -0,0 +1,286 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/containerd/containerd" + cerrdefs "github.com/containerd/containerd/errdefs" + gocni "github.com/containerd/go-cni" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containerd/nerdctl/pkg/netutil/nettype" + "github.com/containerd/nerdctl/pkg/strutil" + "github.com/containernetworking/cni/libcni" + "github.com/sirupsen/logrus" +) + +const ( + // go-cni default + // From https://github.com/containerd/go-cni/blob/31de2455ae5d8bfc743572bfe73587ca7468f865/types.go + interfacePrefix = "eth" + + // This container label stores the current maximum network index. + // Eg: if the network index is 1, a new network will be added with interface name eth2 and the index will be updated. + // Maintaining this index is important as opposed to just using the length of networks as the index, + // because the index may be different from the length if a network was removed using the network disconnect API. + networkIndexLabel = "finch/network-index" +) + +func (s *service) Connect(ctx context.Context, networkId, containerId string) error { + logrus.Infof("network connect: network Id %s, container Id %s", networkId, containerId) + + net, err := s.getNetwork(networkId) + if err != nil { + logrus.Debugf("Failed to get network: %s", err) + return err + } + container, err := s.getContainer(ctx, containerId) + if err != nil { + logrus.Debugf("Failed to get container: %s", err) + return err + } + status, err := getContainerStatus(ctx, container) + if err != nil { + logrus.Debugf("Failed to get container status: %s", err) + return err + } + + switch status { + case containerd.Unknown: + return fmt.Errorf("failed to determine container status from runtime") + case containerd.Pausing: + return fmt.Errorf("cannot connect a container that is currently pausing") + + // the container runtime has been set up and network namespace already exists, + // connect this network to the existing namespace for the running task + case containerd.Paused, containerd.Running: + deleteFunc, err := addNetworkConfig(ctx, net, container) + if err != nil { + return err + } + err = s.connectNetwork(ctx, net, container) + if err != nil { + // cleanup added network + deleteFunc() + return err + } + return nil + + // default is the case when the container is either stopped or created, i.e. a network namespace does not exist, + // the new network configuration must be added to the existing list of networks + default: + _, err = addNetworkConfig(ctx, net, container) + return err + } +} + +func addNetworkConfig(ctx context.Context, net *netutil.NetworkConfig, container containerd.Container) (func(), error) { + opts, err := container.Labels(ctx) + if err != nil { + logrus.Errorf("Failed to get container labels: %s", err) + return nil, err + } + networksJSON := opts[labels.Networks] + var networks []string + err = json.Unmarshal([]byte(networksJSON), &networks) + if err != nil { + logrus.Errorf("Failed to unmarshal networks json %s: %s", networksJSON, err) + return nil, err + } + if strutil.InStringSlice(networks, net.Name) { + logrus.Debugf("Container %s already connected to network %s", container.ID(), net.Name) + return nil, fmt.Errorf("container %s already connected to network %s", container.ID(), net.Name) + } + + err = verifyNetworkConfig(net, networks, opts[labels.MACAddress]) + if err != nil { + logrus.Debugf("Failed to verify network configuration: %s", err) + return nil, err + } + networks = append(networks, net.Name) + + // update OCI spec + spec, err := container.Spec(ctx) + if err != nil { + logrus.Errorf("Failed to get container OCI spec: %s", err) + return nil, err + } + networksData, err := json.Marshal(networks) + if err != nil { + logrus.Errorf("Failed to marshal networks slice %v: %s", networks, err) + return nil, err + } + opts[labels.Networks] = string(networksData) + index := -1 + if indexString, ok := opts[networkIndexLabel]; ok { + index, err = strconv.Atoi(indexString) + if err != nil { + logrus.Errorf("Invalid network index %s: %s", indexString, err) + return nil, err + } + index = index + 1 + } else { + index = len(networks) - 1 + } + opts[networkIndexLabel] = strconv.Itoa(index) + spec.Annotations[labels.Networks] = string(networksData) + err = container.Update(ctx, + containerd.UpdateContainerOpts(containerd.WithContainerLabels(opts)), + containerd.UpdateContainerOpts(containerd.WithSpec(spec)), + ) + if err != nil { + logrus.Errorf("Failed to update container: %s", err) + return nil, err + } + + // define a garbage collector to remove the added network from container spec, + // to be used when the network cannot be attached successfully. + deleteNet := func() { + opts[networkIndexLabel] = strconv.Itoa(index - 1) + networks = networks[:len(networks)-1] + networksData, err = json.Marshal(networks) + if err != nil { + logrus.Errorf("Could not marshal networks slice %v: %s", networks, err) + return + } + opts[labels.Networks] = string(networksData) + spec.Annotations[labels.Networks] = string(networksData) + err = container.Update(ctx, + containerd.UpdateContainerOpts(containerd.WithContainerLabels(opts)), + containerd.UpdateContainerOpts(containerd.WithSpec(spec)), + ) + if err != nil { + logrus.Errorf("Failed to update container: %s", err) + return + } + } + + return deleteNet, nil +} + +func (s *service) connectNetwork(ctx context.Context, net *netutil.NetworkConfig, container containerd.Container) error { + nsPath, err := getContainerNetNSPath(ctx, container) + if err != nil { + logrus.Errorf("Failed to get container network namespace path: %s", err) + return err + } + networkIndex, err := getNetworkIndex(ctx, container) + if err != nil { + logrus.Errorf("Failed to get container network index: %s", err) + return err + } + + // define CNI ADD configuration + cniAddConfig := &libcni.RuntimeConf{ + ContainerID: container.ID(), + NetNS: nsPath, + IfName: interfacePrefix + strconv.Itoa(networkIndex), + } + opts, err := container.Labels(ctx) + if err != nil { + logrus.Errorf("Failed to get container labels: %s", err) + return err + } + args := [][2]string{{"IgnoreUnknown", "1"}} + if ipAddress, ok := opts[labels.IPAddress]; ok && ipAddress != "" { + args = append(args, [2]string{"IP", ipAddress}) + } + if macAddress, ok := opts[labels.MACAddress]; ok && macAddress != "" { + args = append(args, [2]string{"MAC", macAddress}) + } + if portsJSON, ok := opts[labels.Ports]; ok && portsJSON != "" { + var ports []gocni.PortMapping + if err := json.Unmarshal([]byte(portsJSON), &ports); err != nil { + logrus.Errorf("Failed to unmarshal ports from labels: %s", err) + return err + } + cniAddConfig.CapabilityArgs = make(map[string]interface{}) + cniAddConfig.CapabilityArgs["portMappings"] = ports + } + cniAddConfig.Args = args + + // attach network + _, err = s.netClient.AddNetworkList(ctx, net.NetworkConfigList, cniAddConfig) + if err != nil { + logrus.Errorf("Error attaching network %s to container %s: %s", net.Name, container.ID(), err) + return err + } + + return nil +} + +func getContainerStatus(ctx context.Context, container containerd.Container) (containerd.ProcessStatus, error) { + task, err := container.Task(ctx, nil) + if err != nil { + // no running task found implies the container was created but never started + if cerrdefs.IsNotFound(err) { + return containerd.Created, nil + } + return containerd.Unknown, err + } + status, err := task.Status(ctx) + if err != nil { + return containerd.Unknown, err + } + return status.Status, nil +} + +func getContainerNetNSPath(ctx context.Context, container containerd.Container) (string, error) { + task, err := container.Task(ctx, nil) + if err != nil { + return "", err + } + return fmt.Sprintf("/proc/%d/ns/net", task.Pid()), nil +} + +func getNetworkIndex(ctx context.Context, container containerd.Container) (int, error) { + opts, err := container.Labels(ctx) + if err != nil { + return -1, err + } + if indexString, ok := opts[networkIndexLabel]; ok { + index, err := strconv.Atoi(indexString) + if err != nil { + return -1, err + } + return index, nil + } + + // return the length of existing networks otherwise + networksJSON := opts[labels.Networks] + var networks []string + err = json.Unmarshal([]byte(networksJSON), &networks) + if err != nil { + return -1, err + } + opts[networkIndexLabel] = strconv.Itoa(len(networks) - 1) + _, err = container.SetLabels(ctx, opts) + if err != nil { + return -1, err + } + return len(networks) - 1, nil +} + +func verifyNetworkConfig(net *netutil.NetworkConfig, networks []string, macAddress string) error { + netType, err := nettype.Detect(append(networks, net.Name)) + if err != nil { + return err + } + if netType != nettype.CNI { + return fmt.Errorf("Invalid network %s, only CNI type is supported", net.Name) + } + if macAddress != "" { + macValidNetworks := []string{"bridge", "macvlan"} + netMode := net.Plugins[0].Network.Type + if !strutil.InStringSlice(macValidNetworks, netMode) { + return fmt.Errorf("Network type %q is not supported when MAC address is specified, must be one of: %v", netMode, macValidNetworks) + } + } + return nil +} diff --git a/pkg/service/network/create.go b/pkg/service/network/create.go new file mode 100644 index 00000000..2bacb664 --- /dev/null +++ b/pkg/service/network/create.go @@ -0,0 +1,203 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/containerd/nerdctl/pkg/lockutil" + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/utility/maputility" +) + +// Create implements the logic to turn a network create request to the back-end nerdctl create network calls. +func (s *service) Create(ctx context.Context, request types.NetworkCreateRequest) (types.NetworkCreateResponse, error) { + // enable_ip_masquerade, host_binding_ipv4, and bridge name network options are not supported by nerdctl. + // So we must filter out any unsupported options which would prevent the network from being created and accept the defaults. + bridge := "" + filterUnsupportedOptions := func(original map[string]string) map[string]string { + options := map[string]string{} + for k, v := range original { + switch k { + case "com.docker.network.bridge.enable_ip_masquerade": + // must be true + if v != "true" { + s.logger.Warnf("network option com.docker.network.bridge.enable_ip_masquerade is set to %s, but it must be true", v) + } + case "com.docker.network.bridge.host_binding_ipv4": + // must be 0.0.0.0 + if v != "0.0.0.0" { + s.logger.Warnf("network option com.docker.network.bridge.host_binding_ipv4 is set to %s, but it must be 0.0.0.0", v) + } + case "com.docker.network.bridge.name": + bridge = v + default: + options[k] = v + } + } + return options + } + + createOptionsFrom := func(r types.NetworkCreateRequest) netutil.CreateOptions { + options := netutil.CreateOptions{ + Name: r.Name, + Driver: "bridge", + IPAMDriver: "default", + IPAMOptions: r.IPAM.Options, + Options: filterUnsupportedOptions(r.Options), + Labels: maputility.Flatten(r.Labels, maputility.KeyEqualsValueFormat), + } + if r.Driver != "" { + options.Driver = r.Driver + } + if r.IPAM.Driver != "" { + options.IPAMDriver = r.IPAM.Driver + } + if len(request.IPAM.Config) != 0 { + options.Subnets = []string{} + if subnet, ok := request.IPAM.Config[0]["Subnet"]; ok { + options.Subnets = []string{subnet} + } + if ipRange, ok := request.IPAM.Config[0]["IPRange"]; ok { + options.IPRange = ipRange + } + if gateway, ok := request.IPAM.Config[0]["Gateway"]; ok { + options.Gateway = gateway + } + } + return options + } + + if config, err := s.getNetwork(request.Name); err == nil { + // Network already exists; however, it may not have a network ID. + response := types.NetworkCreateResponse{ + Warning: fmt.Sprintf("Network with name '%s' already exists", request.Name), + } + if config != nil && config.NerdctlID != nil { + // Share the network ID if it is available. + response.ID = *config.NerdctlID + response.Warning = fmt.Sprintf("Network with name '%s' (id: %s) already exists", request.Name, *config.NerdctlID) + } + return response, nil + } + + net, err := s.netClient.CreateNetwork(createOptionsFrom(request)) + warning := "" + if err != nil && strings.Contains(err.Error(), "unsupported cni driver") { + return types.NetworkCreateResponse{}, errdefs.NewNotFound(errPluginNotFound) + } else if err != nil { + return types.NetworkCreateResponse{}, err + } else if net == nil || net.NerdctlID == nil { + // The create network call to nerdctl was successful, but no network ID was returned. + // This should not happen. + return types.NetworkCreateResponse{}, errNetworkIDNotFound + } + + // Since nerdctl currently does not support custom bridge names, + // we explicitly override bridge name in the conflist file for the network that was just created + if bridge != "" { + if err = s.setBridgeName(net, bridge); err != nil { + warning = fmt.Sprintf("Failed to set network bridge name %s: %s", bridge, err) + } + } + + return types.NetworkCreateResponse{ + ID: *net.NerdctlID, + Warning: warning, + }, nil +} + +// setBridgeName will override the bridge name in an existing CNI config file for a network +func (s *service) setBridgeName(net *netutil.NetworkConfig, bridge string) error { + return lockutil.WithDirLock(s.netClient.NetconfPath(), func() error { + // first, make sure that the bridge name is not used by any of the existing bridge networks + bridgeNet, err := s.getNetworkByBridgeName(bridge) + if err != nil { + return err + } + if bridgeNet != nil { + return fmt.Errorf("bridge name %s already in use by network %s", bridge, bridgeNet.Name) + } + + // load the CNI config file and set bridge name + configFilename := s.getConfigPathForNetworkName(net.Name) + configFile, err := os.Open(configFilename) + if err != nil { + return err + } + defer configFile.Close() + var netJSON interface{} + if err = json.NewDecoder(configFile).Decode(&netJSON); err != nil { + return err + } + netMap, ok := netJSON.(map[string]interface{}) + if !ok { + return fmt.Errorf("network config file %s is not a valid map", configFilename) + } + plugins, ok := netMap["plugins"] + if !ok { + return fmt.Errorf("could not find plugins in network config file %s", configFilename) + } + pluginsMap, ok := plugins.([]interface{}) + if !ok { + return fmt.Errorf("could not parse plugins in network config file %s", configFilename) + } + for _, plugin := range pluginsMap { + pluginMap, ok := plugin.(map[string]interface{}) + if !ok { + continue + } + if pluginMap["type"] == "bridge" { + pluginMap["bridge"] = bridge + data, err := json.MarshalIndent(netJSON, "", " ") + if err != nil { + return err + } + return os.WriteFile(configFilename, data, 0644) + } + } + return fmt.Errorf("bridge plugin not found in network config file %s", configFilename) + }) +} + +// From https://github.com/containerd/nerdctl/blob/v1.5.0/pkg/netutil/netutil.go#L186-L188 +func (s *service) getConfigPathForNetworkName(netName string) string { + return filepath.Join(s.netClient.NetconfPath(), "nerdctl-"+netName+".conflist") +} + +type bridgePlugin struct { + Type string `json:"type"` + Bridge string `json:"bridge"` +} + +func (s *service) getNetworkByBridgeName(bridge string) (*netutil.NetworkConfig, error) { + networks, err := s.netClient.FilterNetworks(func(*netutil.NetworkConfig) bool { + return true + }) + if err != nil { + return nil, err + } + for _, network := range networks { + for _, plugin := range network.Plugins { + if plugin.Network.Type != "bridge" { + continue + } + var bridgeJSON bridgePlugin + if err = json.Unmarshal(plugin.Bytes, &bridgeJSON); err != nil { + continue + } + if bridgeJSON.Bridge == bridge { + return network, nil + } + } + } + return nil, nil +} diff --git a/pkg/service/network/create_test.go b/pkg/service/network/create_test.go new file mode 100644 index 00000000..749f8cd4 --- /dev/null +++ b/pkg/service/network/create_test.go @@ -0,0 +1,338 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + "errors" + + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/network" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Network Service Create Network Implementation", func() { + const ( + networkName = "test-network" + networkID = "f2ce5cdfcb34238294c247a218b764347f78e55b0f61d00c6364df0ffe3a1de9" + ) + + var ( + ctx context.Context + mockController *gomock.Controller + cdClient *mocks_backend.MockContainerdClient + ncNetClient *mocks_backend.MockNerdctlNetworkSvc + logger *mocks_logger.Logger + service network.Service + ) + + BeforeEach(func() { + ctx = context.Background() + mockController = gomock.NewController(GinkgoT()) + cdClient = mocks_backend.NewMockContainerdClient(mockController) + ncNetClient = mocks_backend.NewMockNerdctlNetworkSvc(mockController) + logger = mocks_logger.NewLogger(mockController) + service = NewService(cdClient, ncNetClient, logger) + }) + + When("a create network call is successful", func() { + It("should return the network ID", func() { + request := types.NewCreateNetworkRequest(networkName) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + var nid = networkID + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).Return(&netutil.NetworkConfig{ + NerdctlID: &nid, + }, nil) + + response, err := service.Create(ctx, *request) + Expect(response.ID).Should(Equal(networkID)) + Expect(err).ShouldNot(HaveOccurred()) + }) + }) + + Context("a network already exists", func() { + When("a request collides with an already existing user defined network", func() { + It("should return the network ID and a warning that the network exists already", func() { + request := types.NewCreateNetworkRequest(networkName) + + var nid = networkID + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{{NerdctlID: &nid}}, nil) + + response, err := service.Create(ctx, *request) + Expect(err).ShouldNot(HaveOccurred()) + Expect(response.ID).Should(Equal(networkID)) + Expect(response.Warning).Should(ContainSubstring("already exists")) + }) + }) + + When("a request collides with an already existing default network", func() { + It("should return a warning that the network exists already", func() { + request := types.NewCreateNetworkRequest(networkName) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{{NerdctlID: nil}}, nil) + + response, err := service.Create(ctx, *request) + Expect(err).ShouldNot(HaveOccurred()) + Expect(response.ID).Should(BeEmpty()) + Expect(response.Warning).Should(ContainSubstring("already exists")) + }) + }) + }) + + When("a network plugin is not supported", func() { + It("should return an error the driver was not found", func() { + request := types.NewCreateNetworkRequest(networkName) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).Return(nil, errUnsupportedCNIDriver) + + response, err := service.Create(ctx, *request) + Expect(response.ID).Should(BeEmpty()) + Expect(err).Should(HaveOccurred()) + Expect(err).Should(MatchError(ContainSubstring("not found"))) + }) + }) + + Context("returns from nerdctl which should not happen", func() { + When("nerdctl successfully creates the network but returns nil network", func() { + It("should return an error that the network ID was not found", func() { + request := types.NewCreateNetworkRequest(networkName) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).Return(nil, nil) + + response, err := service.Create(ctx, *request) + Expect(response.ID).Should(BeEmpty()) + Expect(err).Should(HaveOccurred()) + Expect(err).Should(MatchError(ContainSubstring("not found"))) + }) + }) + + When("nerdctl successfully creates the network but does not return a network ID", func() { + It("should return an error that the network ID was not found", func() { + request := types.NewCreateNetworkRequest(networkName) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).Return(&netutil.NetworkConfig{}, nil) + + response, err := service.Create(ctx, *request) + Expect(response.ID).Should(BeEmpty()) + Expect(err).Should(HaveOccurred()) + Expect(err).Should(MatchError(ContainSubstring("not found"))) + }) + }) + }) + + When("a create network error occurs", func() { + It("should return the error", func() { + request := types.NewCreateNetworkRequest(networkName) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + errFromNerd := errors.New("create network failed") + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).Return(nil, errFromNerd) + + response, err := service.Create(ctx, *request) + Expect(response.ID).Should(BeEmpty()) + Expect(err).Should(HaveOccurred()) + Expect(err).Should(Equal(errFromNerd)) + }) + }) + + Context("Nerdctl default configuration", func() { + const ( + defaultExpectedDriver = "bridge" + defaultExpectedIPAMDriver = "default" + + overrideExpectedDriver = "baby" + overrideExpectedIPAMDriver = "baby-ipam" + ) + + When("a request is missing nerdctl required configuration", func() { + It("should apply the default configuration", func() { + request := types.NewCreateNetworkRequest(networkName) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + var nid = networkID + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).DoAndReturn(func(actual netutil.CreateOptions) (*netutil.NetworkConfig, error) { + Expect(actual.Driver).Should(Equal(defaultExpectedDriver)) + Expect(actual.IPAMDriver).Should(Equal(defaultExpectedIPAMDriver)) + return &netutil.NetworkConfig{NerdctlID: &nid}, nil + }) + + service.Create(ctx, *request) + }) + }) + + When("a request provides nerdctl required configuration", func() { + It("should override the default configuration", func() { + request := types.NewCreateNetworkRequest( + networkName, + types.WithDriver(overrideExpectedDriver), + types.WithIPAM(types.IPAM{Driver: overrideExpectedIPAMDriver}), + ) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + var nid = networkID + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).DoAndReturn(func(actual netutil.CreateOptions) (*netutil.NetworkConfig, error) { + Expect(actual.Driver).Should(Equal(overrideExpectedDriver)) + Expect(actual.IPAMDriver).Should(Equal(overrideExpectedIPAMDriver)) + return &netutil.NetworkConfig{NerdctlID: &nid}, nil + }) + + service.Create(ctx, *request) + }) + }) + }) + + Context("IPAM configuration", func() { + const ( + expectedIPRange = "172.20.10.0/24" + expectedGateway = "172.20.10.11" + ) + expectedSubnets := []string{"172.20.0.0/16"} + When("multiple IPAM configuration entries are specified", func() { + It("should use the first IPAM object", func() { + request := types.NewCreateNetworkRequest( + networkName, + types.WithIPAM(types.IPAM{ + Driver: "default", + Config: []map[string]string{ + { + "Subnet": expectedSubnets[0], + "IPRange": expectedIPRange, + "Gateway": expectedGateway, + }, + { + "Subnet": "2001:db8:abcd::/64", + "Gateway": "2001:db8:abcd::1011", + }, + }, + }), + ) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + var nid = networkID + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).DoAndReturn(func(actual netutil.CreateOptions) (*netutil.NetworkConfig, error) { + Expect(actual.Subnets).Should(Equal(expectedSubnets)) + Expect(actual.IPRange).Should(Equal(expectedIPRange)) + Expect(actual.Gateway).Should(Equal(expectedGateway)) + return &netutil.NetworkConfig{NerdctlID: &nid}, nil + }) + + service.Create(ctx, *request) + }) + }) + + Context("partial IPAM configuration", func() { + When("only subnet is specified", func() { + It("should use the configuration that is available", func() { + request := types.NewCreateNetworkRequest( + networkName, + types.WithIPAM(types.IPAM{ + Driver: "default", + Config: []map[string]string{ + { + "Subnet": expectedSubnets[0], + }, + }, + }), + ) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + var nid = networkID + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).DoAndReturn(func(actual netutil.CreateOptions) (*netutil.NetworkConfig, error) { + Expect(actual.Subnets).Should(Equal(expectedSubnets)) + Expect(actual.IPRange).Should(BeEmpty()) + Expect(actual.Gateway).Should(BeEmpty()) + return &netutil.NetworkConfig{NerdctlID: &nid}, nil + }) + + service.Create(ctx, *request) + }) + }) + + When("only IP range is specified", func() { + It("should use the configuration that is available", func() { + request := types.NewCreateNetworkRequest( + networkName, + types.WithIPAM(types.IPAM{ + Driver: "default", + Config: []map[string]string{ + { + "IPRange": expectedIPRange, + }, + }, + }), + ) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + var nid = networkID + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).DoAndReturn(func(actual netutil.CreateOptions) (*netutil.NetworkConfig, error) { + Expect(actual.Subnets).Should(BeEmpty()) + Expect(actual.IPRange).Should(Equal(expectedIPRange)) + Expect(actual.Gateway).Should(BeEmpty()) + return &netutil.NetworkConfig{NerdctlID: &nid}, nil + }) + + service.Create(ctx, *request) + }) + }) + + When("only gateway is specified", func() { + It("should use the configuration that is available", func() { + request := types.NewCreateNetworkRequest( + networkName, + types.WithIPAM(types.IPAM{ + Driver: "default", + Config: []map[string]string{ + { + "Gateway": expectedGateway, + }, + }, + }), + ) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + var nid = networkID + ncNetClient.EXPECT().CreateNetwork(gomock.Any()).DoAndReturn(func(actual netutil.CreateOptions) (*netutil.NetworkConfig, error) { + Expect(actual.Subnets).Should(BeEmpty()) + Expect(actual.IPRange).Should(BeEmpty()) + Expect(actual.Gateway).Should(Equal(expectedGateway)) + return &netutil.NetworkConfig{NerdctlID: &nid}, nil + }) + + service.Create(ctx, *request) + }) + }) + }) + }) +}) diff --git a/pkg/service/network/inspect.go b/pkg/service/network/inspect.go new file mode 100644 index 00000000..26f605e2 --- /dev/null +++ b/pkg/service/network/inspect.go @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// Inspect returns the Name and Id of the network given an Id or the Name +func (s *service) Inspect(ctx context.Context, networkId string) (*types.NetworkInspectResponse, error) { + s.logger.Infof("network inspect: network Id %s", networkId) + n, err := s.getNetwork(networkId) + if err != nil { + s.logger.Debugf("Failed to get network: %s", err) + return nil, err + } + network, err := s.netClient.InspectNetwork(ctx, n) + if err != nil { + s.logger.Debugf("Failed to inspect network: %s", err) + return nil, err + } + + netObject := &types.NetworkInspectResponse{ + Name: network.Name, + ID: network.ID, + IPAM: network.IPAM, + Labels: network.Labels, + } + + return netObject, nil +} diff --git a/pkg/service/network/inspect_test.go b/pkg/service/network/inspect_test.go new file mode 100644 index 00000000..0aa01beb --- /dev/null +++ b/pkg/service/network/inspect_test.go @@ -0,0 +1,134 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + "errors" + "fmt" + + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containernetworking/cni/libcni" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/network" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Network Inspect API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + cdClient *mocks_backend.MockContainerdClient + ncNetClient *mocks_backend.MockNerdctlNetworkSvc + logger *mocks_logger.Logger + service network.Service + networkId string + networkName string + mockNetworkConfig *netutil.NetworkConfig + mockNetworkInspect *dockercompat.Network + expNetworkResp *types.NetworkInspectResponse + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncNetClient = mocks_backend.NewMockNerdctlNetworkSvc(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + service = NewService(cdClient, ncNetClient, logger) + // initialize dummy values + networkId = "123" + networkName = "test" + mockNetworkConfig = &netutil.NetworkConfig{ + NetworkConfigList: &libcni.NetworkConfigList{ + Name: networkName, + }, + NerdctlID: &networkId, + } + mockNetworkInspect = &dockercompat.Network{ + ID: networkId, + Name: networkName, + Labels: map[string]string{"testLabel": "testValue"}, + IPAM: dockercompat.IPAM{ + Config: []dockercompat.IPAMConfig{ + {Subnet: "10.5.2.0/24", Gateway: "10.5.2.1"}, + }, + }, + } + expNetworkResp = &types.NetworkInspectResponse{ + ID: networkId, + Name: networkName, + Labels: mockNetworkInspect.Labels, + IPAM: mockNetworkInspect.IPAM, + } + }) + Context("service", func() { + It("should not return any error", func() { + logger.EXPECT().Infof("network inspect: network Id %s", networkId) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{mockNetworkConfig}, nil) + ncNetClient.EXPECT().InspectNetwork(gomock.Any(), mockNetworkConfig).Return(mockNetworkInspect, nil) + + resp, err := service.Inspect(ctx, networkId) + Expect(err).Should(BeNil()) + Expect(resp).Should(Equal(expNetworkResp)) + }) + It("should pass through errors from FilterNetworks", func() { + logger.EXPECT().Infof("network inspect: network Id %s", networkId) + + mockErr := fmt.Errorf("error from FilterNetworks") + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, mockErr) + logger.EXPECT().Errorf("failed to search network: %s. error: %s", networkId, mockErr.Error()) + logger.EXPECT().Debugf("Failed to get network: %s", mockErr) + + resp, err := service.Inspect(ctx, networkId) + Expect(err).Should(Equal(mockErr)) + Expect(resp).Should(BeNil()) + }) + It("should return a notFound error when no network is found", func() { + logger.EXPECT().Infof("network inspect: network Id %s", networkId) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{}, nil) + logger.EXPECT().Debugf("no such network %s", networkId) + logger.EXPECT().Debugf("Failed to get network: %s", gomock.Any()) + + resp, err := service.Inspect(ctx, networkId) + Expect(err).ShouldNot(BeNil()) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + Expect(resp).Should(BeNil()) + }) + It("should return an error when multiple networks are found", func() { + logger.EXPECT().Infof("network inspect: network Id %s", networkId) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{mockNetworkConfig, mockNetworkConfig}, nil) + logger.EXPECT().Debugf("multiple IDs found with provided prefix: %s, total networks found: %d", + networkId, 2) + logger.EXPECT().Debugf("Failed to get network: %s", gomock.Any()) + + resp, err := service.Inspect(ctx, networkId) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal(fmt.Sprintf("multiple networks found with ID: %s", networkId))) + Expect(resp).Should(BeNil()) + }) + It("should return an error when InspectNetwork fails", func() { + inspectErr := errors.New("network inspect error") + logger.EXPECT().Infof("network inspect: network Id %s", networkId) + + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{mockNetworkConfig}, nil) + ncNetClient.EXPECT().InspectNetwork(gomock.Any(), mockNetworkConfig).Return(nil, inspectErr) + logger.EXPECT().Debugf("Failed to inspect network: %s", inspectErr) + + resp, err := service.Inspect(ctx, networkId) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal(inspectErr.Error())) + Expect(resp).Should(BeNil()) + }) + }) +}) diff --git a/pkg/service/network/list.go b/pkg/service/network/list.go new file mode 100644 index 00000000..de333d4c --- /dev/null +++ b/pkg/service/network/list.go @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// List returns an array of network objects based on the filtered criteria. Nerdctl's pkg.cmd.network.List is not used +// as the output format is different from ours +func (s *service) List(ctx context.Context) ([]*types.NetworkInspectResponse, error) { + getAllFilterFunc := func(n *netutil.NetworkConfig) bool { + return true + } + + nl, err := s.netClient.FilterNetworks(getAllFilterFunc) + if err != nil { + return nil, err + } + + summaries := make([]*types.NetworkInspectResponse, len(nl)) + + for i, n := range nl { + network := &types.NetworkInspectResponse{ + Name: n.Name, + } + if n.NerdctlID != nil { + network.ID = *n.NerdctlID + } + summaries[i] = network + } + + return summaries, nil +} diff --git a/pkg/service/network/list_test.go b/pkg/service/network/list_test.go new file mode 100644 index 00000000..1b236aba --- /dev/null +++ b/pkg/service/network/list_test.go @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + "fmt" + + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containernetworking/cni/libcni" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/network" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Network List API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + cdClient *mocks_backend.MockContainerdClient + ncNetClient *mocks_backend.MockNerdctlNetworkSvc + logger *mocks_logger.Logger + service network.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncNetClient = mocks_backend.NewMockNerdctlNetworkSvc(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + service = NewService(cdClient, ncNetClient, logger) + }) + Context("service", func() { + It("should return 0 networks when nothing is found", func() { + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return(nil, nil) + + resp, err := service.List(ctx) + Expect(err).Should(BeNil()) + Expect(len(resp)).Should(Equal(0)) + }) + It("should pass through errors from FilterNetworks", func() { + expErr := "filter network error" + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return(nil, fmt.Errorf(expErr)) + + _, err := service.List(ctx) + Expect(err.Error()).Should(Equal(expErr)) + }) + It("should return the found networks", func() { + expNetName := "testnet" + expNetID := "abcdefg" + + expNetList := make([]*netutil.NetworkConfig, 1) + expNetList[0] = &netutil.NetworkConfig{ + NetworkConfigList: &libcni.NetworkConfigList{Name: expNetName}, + NerdctlID: &expNetID, + } + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return(expNetList, nil) + + resp, err := service.List(ctx) + Expect(err).Should(BeNil()) + Expect(len(resp)).Should(Equal(1)) + Expect(resp[0].Name).Should(Equal(expNetName)) + Expect(resp[0].ID).Should(Equal(expNetID)) + }) + }) +}) diff --git a/pkg/service/network/network.go b/pkg/service/network/network.go new file mode 100644 index 00000000..b12c23b4 --- /dev/null +++ b/pkg/service/network/network.go @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + "errors" + "fmt" + "regexp" + + "github.com/containerd/containerd" + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/runfinch/finch-daemon/pkg/api/handlers/network" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +var ( + errUnsupportedCNIDriver = errors.New("unsupported cni driver") + errPluginNotFound = errors.New("plugin not found") + errNetworkIDNotFound = errors.New("network ID not found") +) + +type service struct { + client backend.ContainerdClient + netClient backend.NerdctlNetworkSvc + logger flog.Logger +} + +func NewService(client backend.ContainerdClient, netClient backend.NerdctlNetworkSvc, logger flog.Logger) network.Service { + return &service{ + client: client, + netClient: netClient, + logger: logger, + } +} + +func (s *service) getNetwork(networkId string) (*netutil.NetworkConfig, error) { + longIDExp, err := regexp.Compile(fmt.Sprintf("^sha256:%s.*", regexp.QuoteMeta(networkId))) + if err != nil { + return nil, err + } + + shortIDExp, err := regexp.Compile(fmt.Sprintf("^%s", regexp.QuoteMeta(networkId))) + if err != nil { + return nil, err + } + + idFilterFunc := func(n *netutil.NetworkConfig) bool { + if n.NerdctlID == nil { + // External network + return n.Name == networkId + } + return n.Name == networkId || longIDExp.Match([]byte(*n.NerdctlID)) || shortIDExp.Match([]byte(*n.NerdctlID)) + } + + networks, err := s.netClient.FilterNetworks(idFilterFunc) + if err != nil { + s.logger.Errorf("failed to search network: %s. error: %s", networkId, err.Error()) + return nil, err + } + if len(networks) == 0 { + s.logger.Debugf("no such network %s", networkId) + return nil, errdefs.NewNotFound(fmt.Errorf("network %s not found", networkId)) + } + if len(networks) > 1 { + s.logger.Debugf("multiple IDs found with provided prefix: %s, total networks found: %d", + networkId, len(networks)) + return nil, fmt.Errorf("multiple networks found with ID: %s", networkId) + } + + return networks[0], nil +} + +func (s *service) getContainer(ctx context.Context, containerId string) (containerd.Container, error) { + searchResult, err := s.client.SearchContainer(ctx, containerId) + if err != nil { + s.logger.Errorf("failed to search container: %s. error: %s", containerId, err.Error()) + return nil, err + } + matchCount := len(searchResult) + + // if container not found then return NotFound error. + if matchCount == 0 { + s.logger.Debugf("no such container %s", containerId) + return nil, errdefs.NewNotFound(fmt.Errorf("no such container %s", containerId)) + } + // if more than one container found with the provided id return error. + if matchCount > 1 { + s.logger.Debugf("multiple IDs found with provided prefix: %s, total container found: %d", + containerId, matchCount) + return nil, fmt.Errorf("multiple IDs found with provided prefix: %s", containerId) + } + + return searchResult[0], nil +} diff --git a/pkg/service/network/network_test.go b/pkg/service/network/network_test.go new file mode 100644 index 00000000..d39e7aea --- /dev/null +++ b/pkg/service/network/network_test.go @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// TestNetworkService is the entry point of the network service package's unit tests using ginkgo +func TestNetworkService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Network APIs Service") +} diff --git a/pkg/service/network/remove.go b/pkg/service/network/remove.go new file mode 100644 index 00000000..674bb49e --- /dev/null +++ b/pkg/service/network/remove.go @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + "fmt" + + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +func (s *service) Remove(ctx context.Context, networkId string) error { + s.logger.Infof("network delete: network Id %s", networkId) + net, err := s.getNetwork(networkId) + if err != nil { + return fmt.Errorf("failed to find network: %w", err) + } + usedNetworkInfo, err := s.netClient.UsedNetworkInfo(ctx) + if err != nil { + return fmt.Errorf("failed to find used network info: %w", err) + } + // API Doc: https://docs.docker.com/engine/api/v1.43/#tag/Network/operation/NetworkDelete + // does not explicitly call out the scenario when network is in use by a container, although it returns a 403 + if value, ok := usedNetworkInfo[net.Name]; ok { + return errdefs.NewForbidden(fmt.Errorf("network %q is in use by container %q", networkId, value)) + } + if net.File == "" { + return errdefs.NewForbidden(fmt.Errorf("%s is a pre-defined network and cannot be removed", networkId)) + } + return s.netClient.RemoveNetwork(net) +} diff --git a/pkg/service/network/remove_test.go b/pkg/service/network/remove_test.go new file mode 100644 index 00000000..0a84f049 --- /dev/null +++ b/pkg/service/network/remove_test.go @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package network + +import ( + "context" + "fmt" + + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containernetworking/cni/libcni" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/network" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Network Remove API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + cdClient *mocks_backend.MockContainerdClient + ncNetClient *mocks_backend.MockNerdctlNetworkSvc + logger *mocks_logger.Logger + service network.Service + networkId string + networkName string + file string + mockNetworkConfig *netutil.NetworkConfig + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncNetClient = mocks_backend.NewMockNerdctlNetworkSvc(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + service = NewService(cdClient, ncNetClient, logger) + // initialize dummy values + networkId = "123" + networkName = "test" + file = "someFile" + mockNetworkConfig = &netutil.NetworkConfig{ + NetworkConfigList: &libcni.NetworkConfigList{ + Name: networkName, + }, + NerdctlID: &networkId, + File: file, + } + }) + Context("service", func() { + It("should not return any error", func() { + logger.EXPECT().Infof("network delete: network Id %s", networkId) + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{mockNetworkConfig}, nil) + ncNetClient.EXPECT().UsedNetworkInfo(gomock.Any()).Return(make(map[string][]string), nil) + ncNetClient.EXPECT().RemoveNetwork(mockNetworkConfig).Return(nil) + err := service.Remove(ctx, networkId) + Expect(err).Should(BeNil()) + }) + It("should return forbidden error when network is used by a container", func() { + logger.EXPECT().Infof("network delete: network Id %s", networkId) + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{mockNetworkConfig}, nil) + // Map literal to represent network "test" used by container "container" + u := map[string][]string{ + "test": {"container"}, + } + ncNetClient.EXPECT().UsedNetworkInfo(gomock.Any()).Return(u, nil) + err := service.Remove(ctx, networkId) + Expect(err.Error()).Should(Equal(fmt.Errorf("network %q is in use by container %q", networkId, u[networkName]).Error())) + Expect(errdefs.IsForbiddenError(err)).Should(Equal(true)) + }) + It("should return forbidden error when attempting to remove predefined networks", func() { + logger.EXPECT().Infof("network delete: network Id %s", networkId) + ncNetClient.EXPECT().FilterNetworks(gomock.Any()).Return([]*netutil.NetworkConfig{mockNetworkConfig}, nil) + ncNetClient.EXPECT().UsedNetworkInfo(gomock.Any()).Return(make(map[string][]string), nil) + mockNetworkConfig.File = "" + err := service.Remove(ctx, networkId) + Expect(err.Error()).Should(Equal(fmt.Errorf("%s is a pre-defined network and cannot be removed", networkId).Error())) + Expect(errdefs.IsForbiddenError(err)).Should(Equal(true)) + }) + }) +}) diff --git a/pkg/service/system/auth.go b/pkg/service/system/auth.go new file mode 100644 index 00000000..6f566352 --- /dev/null +++ b/pkg/service/system/auth.go @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "net/url" + + "github.com/containerd/containerd/remotes/docker" + dockerconfig "github.com/containerd/containerd/remotes/docker/config" + remoteerrs "github.com/containerd/containerd/remotes/errors" + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "golang.org/x/net/context/ctxhttp" +) + +// To be consistent with nerdctl: https://github.com/containerd/nerdctl/blob/2b06050d782c27571c98947ac9fa790d5f2d0bde/cmd/nerdctl/login.go#L90 +const defaultRegHost = dockerconfigresolver.IndexServer + +func (s *service) Auth(ctx context.Context, username, password, serverAddr string) (string, error) { + if serverAddr == "" { + serverAddr = defaultRegHost + } + + host := dockerconfigresolver.ConvertToHostname(serverAddr) + // TODO: Support server addr that starts with "http://" (probably useful when testing) + // Currently TLS is enforced. + // Check dockerconfigresolver.WithSkipVerifyCerts and dockerconfigresolver.WithPlainHTTP. + ho, err := dockerconfigresolver.NewHostOptions(ctx, host, dockerconfigresolver.WithAuthCreds( + func(acArg string) (string, string, error) { + if acArg == host { + return username, password, nil + } + return "", "", fmt.Errorf("expected acArg to be %q, got %q", host, acArg) + }, + )) + if err != nil { + return "", fmt.Errorf("failed to initialize host options: %v", err) + } + + fetchedRefreshTokens := make(map[string]string) + // TODO: Support ad-hoc host certs: + // https://github.com/containerd/nerdctl/blob/1c4029a79bdcb4f728b5bdc534aec36e13a3d2ac/cmd/nerdctl/login.go#L223 + // By default, the host's root CA set is used, so usually this is not needed because + // established registries's certificates (e.g., ECR, Docker Hub, etc.) should work just fine. + // This probably comes in handy when testing (e.g., spin up a registry locally with a self-signed certificate). + ho.AuthorizerOpts = append(ho.AuthorizerOpts, docker.WithFetchRefreshToken( + func(ctx context.Context, token string, req *http.Request) { + fetchedRefreshTokens[req.URL.Host] = token + }, + )) + regHosts, err := dockerconfig.ConfigureHosts(ctx, *ho)(host) + if err != nil { + return "", fmt.Errorf("failed to configure registry host: %w", err) + } + + for _, rh := range regHosts { + if err = loginRegHost(ctx, rh); err != nil { + log.Printf("failed to log in registry host %s: %v", rh.Host, err) + continue + } + // It's possible that the token is empty: + // https://github.com/containerd/containerd/blob/5d4276cc34ddd20454caaae23824b73b6c6907c1/remotes/docker/authorizer.go#L126 + return fetchedRefreshTokens[rh.Host], nil + } + return "", fmt.Errorf("failed to log in to all the registry hosts, last err: %w", err) +} + +func loginRegHost(ctx context.Context, rh docker.RegistryHost) error { + if rh.Authorizer == nil { + return errors.New("got nil Authorizer") + } + // Why we need to manually add the slash here: https://github.com/containerd/containerd/pull/6470#issuecomment-1020664375 + if rh.Path == "/v2" { + // What this endpoint is about: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support + rh.Path = "/v2/" + } + u := url.URL{ + Scheme: rh.Scheme, + Host: rh.Host, + Path: rh.Path, + } + var resps []*http.Response + for i := 0; i < 3; i++ { + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + for k, v := range rh.Header.Clone() { + for _, vv := range v { + req.Header.Add(k, vv) + } + } + if err := rh.Authorizer.Authorize(ctx, req); err != nil { + var usErr remoteerrs.ErrUnexpectedStatus + if errors.As(err, &usErr) { + if usErr.StatusCode == http.StatusUnauthorized { + err = errdefs.NewUnauthenticated(err) + } + } + return fmt.Errorf("failed to call rh.Authorizer.Authorize: %w", err) + } + resp, err := ctxhttp.Do(ctx, rh.Client, req) + if err != nil { + return fmt.Errorf("failed to call rh.Client.Do: %w", err) + } + log.Printf("trial %d, status code: %d", i, resp.StatusCode) + resps = append(resps, resp) + if resp.StatusCode == 401 { + // TODO: figure out why the first request is always 401, and suddenly the second request will be 200. + // Maybe AddResponses does some magic. + if err := rh.Authorizer.AddResponses(ctx, resps); err != nil { + return fmt.Errorf("failed to call rh.Authorizer.AddResponses: %w", err) + } + continue + } + if resp.StatusCode/100 != 2 { + return fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + return nil + } + return errdefs.NewUnauthenticated(errors.New("too many 401 (probably)")) +} diff --git a/pkg/service/system/events.go b/pkg/service/system/events.go new file mode 100644 index 00000000..02318cd3 --- /dev/null +++ b/pkg/service/system/events.go @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + "fmt" + + "github.com/containerd/containerd/events" + "github.com/containerd/typeurl/v2" + eventtype "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/sirupsen/logrus" +) + +func (s *service) SubscribeEvents(ctx context.Context, filters map[string][]string) (<-chan *eventtype.Event, <-chan error) { + sendCh := make(chan *eventtype.Event) + sendErrCh := make(chan error) + + go func() { + defer close(sendCh) + defer close(sendErrCh) + + eventCh, errCh := s.client.SubscribeToEvents(ctx, containerdFiltersFromAPIFilters(filters)...) + + for { + var e *events.Envelope + select { + case e = <-eventCh: + case err := <-errCh: + sendErrCh <- err + return + case <-ctx.Done(): + if cerr := ctx.Err(); cerr != nil { + sendErrCh <- cerr + } + return + } + if e != nil { + var event *eventtype.Event + + if e.Event != nil { + v, err := typeurl.UnmarshalAny(e.Event) + if err != nil { + logrus.Errorf("error unmarshaling event: %q\n", err) + continue + } + event = v.(*eventtype.Event) + } else { + continue + } + + event.Scope = "local" + event.Time = e.Timestamp.Unix() + event.TimeNano = e.Timestamp.UnixNano() + + sendCh <- event + } + } + }() + + return sendCh, sendErrCh +} + +func containerdFiltersFromAPIFilters(filters map[string][]string) []string { + containerdFilters := []string{ + fmt.Sprintf(`topic~="/%s/*"`, eventtype.CompatibleTopicPrefix), + } + + for filterType, filterList := range filters { + switch filterType { + case "type": + // pop off general topic filter + containerdFilters = containerdFilters[1:] + for _, eventType := range filterList { + containerdFilters = append(containerdFilters, fmt.Sprintf(`topic~="/%s/%s/*"`, + eventtype.CompatibleTopicPrefix, eventType)) + } + default: + // NOP + } + } + + return containerdFilters +} diff --git a/pkg/service/system/events_test.go b/pkg/service/system/events_test.go new file mode 100644 index 00000000..2c30bccc --- /dev/null +++ b/pkg/service/system/events_test.go @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + "fmt" + "time" + + "github.com/containerd/containerd/events" + "github.com/containerd/typeurl/v2" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + eventtype "github.com/runfinch/finch-daemon/pkg/api/events" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" +) + +var _ = Describe("Events API ", func() { + var ( + mockCtrl *gomock.Controller + ctx context.Context + client *mocks_backend.MockContainerdClient + s *service + mockEventCh chan *events.Envelope + mockErrCh chan error + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + ctx = context.Background() + client = mocks_backend.NewMockContainerdClient(mockCtrl) + s = &service{ + client: client, + } + }) + Context("service", func() { + It("should stream events to a channel", func() { + mockEventCh = make(chan *events.Envelope) + mockErrCh = make(chan error) + + client.EXPECT().SubscribeToEvents(ctx, containerdFiltersFromAPIFilters(map[string][]string{})).Return(mockEventCh, mockErrCh) + + ch, _ := s.SubscribeEvents(ctx, map[string][]string{}) + + event := &eventtype.Event{ + Type: "test", + Action: "test", + Actor: eventtype.EventActor{ + Id: "123", + Attributes: map[string]string{"test": "test"}, + }, + } + eventAny, err := typeurl.MarshalAny(event) + Expect(err).Should(BeNil()) + + now := time.Now() + + mockEventCh <- &events.Envelope{ + Timestamp: now, + Topic: "/dockercompat/image/tag", + Namespace: "finch", + Event: eventAny, + } + + gotEvent := <-ch + Expect(gotEvent).ShouldNot(BeNil()) + Expect(gotEvent.Type).Should(Equal(event.Type)) + Expect(gotEvent.Action).Should(Equal(event.Action)) + Expect(gotEvent.Actor).Should(Equal(event.Actor)) + Expect(gotEvent.Time).Should(Equal(now.Unix())) + Expect(gotEvent.TimeNano).Should(Equal(now.UnixNano())) + Expect(gotEvent.Scope).Should(Equal("local")) + + close(mockEventCh) + close(mockErrCh) + }) + It("should forward errors to a channel", func() { + mockEventCh = make(chan *events.Envelope) + mockErrCh = make(chan error) + + client.EXPECT().SubscribeToEvents(ctx, containerdFiltersFromAPIFilters(map[string][]string{})).Return(mockEventCh, mockErrCh) + + _, ch := s.SubscribeEvents(ctx, map[string][]string{}) + + err := fmt.Errorf("mock error") + mockErrCh <- err + + gotErr := <-ch + Expect(gotErr).Should(Equal(err)) + + close(mockEventCh) + close(mockErrCh) + }) + }) + Context("containerdFiltersFromAPIFilters", func() { + It("should return the docker compatible filter if no other filters are provided", func() { + filters := containerdFiltersFromAPIFilters(map[string][]string{}) + Expect(len(filters)).Should(Equal(1)) + Expect(filters[0]).Should(Equal(fmt.Sprintf(`topic~="/%s/*"`, eventtype.CompatibleTopicPrefix))) + }) + It("should include a more specific filter if a type filter is provided", func() { + filters := containerdFiltersFromAPIFilters(map[string][]string{"type": {"test"}}) + Expect(len(filters)).Should(Equal(1)) + Expect(filters[0]).Should(Equal(fmt.Sprintf(`topic~="/%s/%s/*"`, eventtype.CompatibleTopicPrefix, "test"))) + }) + It("should be able to support multiple type filters", func() { + filters := containerdFiltersFromAPIFilters(map[string][]string{"type": {"test1", "test2"}}) + Expect(len(filters)).Should(Equal(2)) + Expect(filters[0]).Should(Equal(fmt.Sprintf(`topic~="/%s/%s/*"`, eventtype.CompatibleTopicPrefix, "test1"))) + Expect(filters[1]).Should(Equal(fmt.Sprintf(`topic~="/%s/%s/*"`, eventtype.CompatibleTopicPrefix, "test2"))) + }) + }) + +}) diff --git a/pkg/service/system/info.go b/pkg/service/system/info.go new file mode 100644 index 00000000..043bdff9 --- /dev/null +++ b/pkg/service/system/info.go @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + + "github.com/containerd/nerdctl/pkg/config" + "github.com/containerd/nerdctl/pkg/infoutil" + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" +) + +func (s *service) GetInfo(ctx context.Context, config *config.Config) (*dockercompat.Info, error) { + return infoutil.Info(ctx, s.client.GetClient(), config.Snapshotter, config.CgroupManager) +} diff --git a/pkg/service/system/system.go b/pkg/service/system/system.go new file mode 100644 index 00000000..aae7ede0 --- /dev/null +++ b/pkg/service/system/system.go @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "github.com/runfinch/finch-daemon/pkg/api/handlers/system" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +type service struct { + client backend.ContainerdClient + ncSystemSvc backend.NerdctlSystemSvc + logger flog.Logger +} + +func NewService(client backend.ContainerdClient, ncSystemSvc backend.NerdctlSystemSvc, logger flog.Logger) system.Service { + return &service{ + client: client, + logger: logger, + ncSystemSvc: ncSystemSvc, + } +} diff --git a/pkg/service/system/system_test.go b/pkg/service/system/system_test.go new file mode 100644 index 00000000..dd667bd7 --- /dev/null +++ b/pkg/service/system/system_test.go @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// TestSystemService is the entry point of system service package's unit tests using ginkgo +func TestSystemService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - System APIs Service") +} diff --git a/pkg/service/system/version.go b/pkg/service/system/version.go new file mode 100644 index 00000000..5dec365d --- /dev/null +++ b/pkg/service/system/version.go @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/flog" + "github.com/runfinch/finch-daemon/pkg/version" +) + +func (s *service) GetVersion(ctx context.Context) (*types.VersionInfo, error) { + vInfo := types.VersionInfo{ + Platform: struct{ Name string }{Name: GetPlatformName()}, + Version: version.Version, + ApiVersion: version.DefaultApiVersion, + MinAPIVersion: version.MinimumApiVersion, + GitCommit: version.GitCommit, + Os: getCmdOutput(s.logger, "uname"), + Arch: getCmdOutput(s.logger, "uname", "-m"), + KernelVersion: getCmdOutput(s.logger, "uname", "-r"), + Experimental: false, + } + sv, err := s.ncSystemSvc.GetServerVersion(ctx) + if err != nil { + s.logger.Warnf("unable to retrieve server component versions: %v", err) + + return nil, err + } + for _, c := range sv.Components { + vInfo.Components = append(vInfo.Components, types.ComponentVersion{ + Name: c.Name, + Version: c.Version, + Details: c.Details, + }) + } + return &vInfo, nil +} + +func getCmdOutput(logger flog.Logger, name string, arg ...string) string { + + out, err := exec.Command(name, arg...).Output() + if err != nil { + logger.Warnf("unable to execute command:%s, error: %v", name, err) + return "" + } + return strings.Trim(string(out), "\n") +} + +func GetPlatformName() string { + return fmt.Sprintf("Finch Daemon - %v", version.Version) +} diff --git a/pkg/service/system/version_test.go b/pkg/service/system/version_test.go new file mode 100644 index 00000000..49b6294e --- /dev/null +++ b/pkg/service/system/version_test.go @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + "fmt" + + "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/system" + "github.com/runfinch/finch-daemon/pkg/api/types" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/version" +) + +// Unit tests related to version API +var _ = Describe("Version API ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + ncClient *mocks_backend.MockNerdctlSystemSvc + service system.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize the mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlSystemSvc(mockCtrl) + cdClient := mocks_backend.NewMockContainerdClient(mockCtrl) + service = NewService(cdClient, ncClient, logger) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() + }) + Context("service", func() { + It("should return version info", func() { + // set up the mock to mimic return containerd component version from nerdctl function + cntdComponent := dockercompat.ComponentVersion{ + Name: "containerd", + Version: "v1.7.1", + Details: map[string]string{ + "GitCommit": "1677a17964311325ed1c31e2c0a3589ce6d5c30d", + }, + } + serverVersion := dockercompat.ServerVersion{ + Components: []dockercompat.ComponentVersion{ + cntdComponent, + }, + } + ncClient.EXPECT().GetServerVersion(ctx).Return(&serverVersion, nil) + // service should not return any error + vInfo, err := service.GetVersion(ctx) + Expect(err).ShouldNot(HaveOccurred()) + Expect(vInfo).ShouldNot(BeNil()) + Expect(vInfo.Version).Should(Equal(version.Version)) + Expect(vInfo.GitCommit).Should(Equal(version.GitCommit)) + Expect(vInfo.ApiVersion).Should(Equal(version.DefaultApiVersion)) + Expect(vInfo.Platform.Name).ShouldNot(BeEmpty()) + Expect(vInfo.Os).ShouldNot(BeEmpty()) + Expect(vInfo.Arch).ShouldNot(BeEmpty()) + Expect(vInfo.KernelVersion).ShouldNot(BeEmpty()) + Expect(vInfo.Components).ShouldNot(BeEmpty()) + Expect(vInfo.Components[0]).Should(Equal(types.ComponentVersion{ + Name: "containerd", + Version: "v1.7.1", + Details: map[string]string{ + "GitCommit": "1677a17964311325ed1c31e2c0a3589ce6d5c30d", + }, + })) + }) + It("should return error", func() { + // set up the mock to mimic return error from nerdctl function + expectedErr := fmt.Errorf("some error") + ncClient.EXPECT().GetServerVersion(gomock.Any()).Return(nil, expectedErr) + logger.EXPECT().Warnf(gomock.Any(), gomock.Any()) + + //service should not return any error + vInfo, err := service.GetVersion(ctx) + Expect(vInfo).Should(BeNil()) + Expect(err).Should(HaveOccurred()) + Expect(err).Should(MatchError(expectedErr)) + }) + }) + +}) diff --git a/pkg/service/volume/create.go b/pkg/service/volume/create.go new file mode 100644 index 00000000..d94ac9f9 --- /dev/null +++ b/pkg/service/volume/create.go @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "context" + + "github.com/containerd/nerdctl/pkg/inspecttypes/native" +) + +// Create a new volume and return the pointer to that volume. +func (s *service) Create(ctx context.Context, name string, labels []string) (*native.Volume, error) { + newVolume, err := s.nctlVolumeSvc.CreateVolume(name, labels) + if err != nil { + s.logger.Errorf("failed to create volume: %v", err) + return nil, err + } + + return newVolume, nil +} diff --git a/pkg/service/volume/create_test.go b/pkg/service/volume/create_test.go new file mode 100644 index 00000000..507684e6 --- /dev/null +++ b/pkg/service/volume/create_test.go @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "context" + "errors" + + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Volumes API service common ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + ncClient *mocks_backend.MockNerdctlVolumeSvc + name string + volume native.Volume + s service + ) + BeforeEach(func() { + // initialize mocks + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlVolumeSvc(mockCtrl) + name = "test-volume" + volume = native.Volume{Name: name} + s = service{ + nctlVolumeSvc: ncClient, + logger: logger, + } + }) + Context("Create Volume", func() { + It("case where volume fails on creation", func() { + ncClient.EXPECT().CreateVolume(gomock.Any(), gomock.Any()).Return(nil, errors.New("fail")) + logger.EXPECT().Errorf("failed to create volume: %v", gomock.Any()) + result, err := s.Create(ctx, "test", []string{}) + Expect(result).Should(BeNil()) + Expect(err).Should(Not(BeNil())) + }) + It("success case where volume was created", func() { + ncClient.EXPECT().CreateVolume(gomock.Any(), gomock.Any()).Return(&volume, nil) + result, err := s.Create(ctx, name, []string{}) + Expect(result).Should(Equal(&volume)) + Expect(err).Should(BeNil()) + }) + }) +}) diff --git a/pkg/service/volume/inspect.go b/pkg/service/volume/inspect.go new file mode 100644 index 00000000..baad74ba --- /dev/null +++ b/pkg/service/volume/inspect.go @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "strings" + + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// Inspect returns a details of a volume. +func (s *service) Inspect(name string) (*native.Volume, error) { + var vol, err = s.nctlVolumeSvc.GetVolume(name) + if err != nil { + // if the volume does not exist, return a NotFound error + // see nerdctl code for exact error msg: + // https://github.com/containerd/nerdctl/blob/main/pkg/mountutil/volumestore/volumestore.go#L134C3-L134C3 + if strings.Contains(err.Error(), "not found") { + err = errdefs.NewNotFound(err) + } + return nil, err + } + return vol, err +} diff --git a/pkg/service/volume/inspect_test.go b/pkg/service/volume/inspect_test.go new file mode 100644 index 00000000..a11e7367 --- /dev/null +++ b/pkg/service/volume/inspect_test.go @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "fmt" + + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/volume" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Inspect volume API", func() { + var ( + mockCtrl *gomock.Controller + ncClient *mocks_backend.MockNerdctlVolumeSvc + name string + s volume.Service + ) + BeforeEach(func() { + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + ncClient = mocks_backend.NewMockNerdctlVolumeSvc(mockCtrl) + logger := mocks_logger.NewLogger(mockCtrl) + name = "test-volume" + s = NewService(ncClient, logger) + }) + Context("service", func() { + It("should return the volume details", func() { + expectedVol := + native.Volume{ + Name: name, + Mountpoint: "/path/to/test-volume", + Labels: nil, + Size: 100, + } + ncClient.EXPECT().GetVolume(name).Return(&expectedVol, nil) + vol, err := s.Inspect(name) + Expect(err).Should(BeNil()) + Expect(*vol).Should(Equal(expectedVol)) + }) + It("should return not found error", func() { + // mock mimics not found error occurred in the nerdctl client + ncClient.EXPECT().GetVolume(name).Return(nil, fmt.Errorf("not found")) + vol, err := s.Inspect(name) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + Expect(vol).Should(BeNil()) + }) + It("should return generic error", func() { + // mock mimics error while retrieving volume details + ncClient.EXPECT().GetVolume(name).Return(nil, fmt.Errorf("some error")) + vol, err := s.Inspect(name) + Expect(errdefs.IsNotFound(err)).Should(BeFalse()) + Expect(vol).Should(BeNil()) + }) + }) +}) diff --git a/pkg/service/volume/list.go b/pkg/service/volume/list.go new file mode 100644 index 00000000..35143f8b --- /dev/null +++ b/pkg/service/volume/list.go @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "context" + + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/runfinch/finch-daemon/pkg/api/types" +) + +// List returns a list of volumes. +func (s *service) List(ctx context.Context, filters []string) (*types.VolumesListResponse, error) { + + // TODO: include size? + vols, err := s.nctlVolumeSvc.ListVolumes(false, filters) + if err != nil { + s.logger.Errorf("failed to list volumes: %v", err) + return nil, err + } + + // initialize so empty response is [] instead of nil + volumes := []native.Volume{} + for _, vol := range vols { + volumes = append(volumes, vol) + } + + return &types.VolumesListResponse{Volumes: volumes}, nil +} diff --git a/pkg/service/volume/list_test.go b/pkg/service/volume/list_test.go new file mode 100644 index 00000000..b2e5cd8c --- /dev/null +++ b/pkg/service/volume/list_test.go @@ -0,0 +1,64 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "context" + "errors" + + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Volumes API service common ", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + ncClient *mocks_backend.MockNerdctlVolumeSvc + logger *mocks_logger.Logger + name string + volume native.Volume + volumeMap map[string]native.Volume + s service + ) + BeforeEach(func() { + // initialize mocks + ctx = context.Background() + mockCtrl = gomock.NewController(GinkgoT()) + ncClient = mocks_backend.NewMockNerdctlVolumeSvc(mockCtrl) + logger = mocks_logger.NewLogger(mockCtrl) + name = "test-volume" + volume = native.Volume{Name: name} + s = service{ + nctlVolumeSvc: ncClient, + logger: logger, + } + volumeMap = make(map[string]native.Volume) + volumeMap[name] = volume + }) + Context("ListVolumes", func() { + It("should return the volume(s) if it was found", func() { + ncClient.EXPECT().ListVolumes(gomock.Any(), gomock.Any()).Return( + volumeMap, nil) + + result, err := s.List(ctx, nil) + Expect(err).Should(BeNil()) + Expect(result.Volumes).ShouldNot(BeEmpty()) + Expect(result.Volumes[0]).Should(Equal(volume)) + }) + It("should return an error if ListVolumes errors", func() { + ncClient.EXPECT().ListVolumes(gomock.Any(), gomock.Any()).Return( + nil, errors.New("fake")) + logger.EXPECT().Errorf("failed to list volumes: %v", errors.New("fake")) + + result, err := s.List(ctx, nil) + Expect(err).Should(Not(BeNil())) + Expect(result).Should(BeNil()) + }) + }) +}) diff --git a/pkg/service/volume/remove.go b/pkg/service/volume/remove.go new file mode 100644 index 00000000..2f0f564c --- /dev/null +++ b/pkg/service/volume/remove.go @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "bufio" + "bytes" + "context" + "strings" + + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// Remove Delete a volume from the system. +func (s *service) Remove(ctx context.Context, volName string, force bool) error { + // pass a dummy writer to the nerdctl, since the stdout output is not required for the remove operation + var buf bytes.Buffer + dummyWriter := bufio.NewWriter(&buf) + + err := s.nctlVolumeSvc.RemoveVolume(ctx, volName, force, dummyWriter) + + if err != nil { + // convert the nerdctl error to a finch specific error to return the appropriate status code + switch { + case strings.Contains(err.Error(), "not found"): + err = errdefs.NewNotFound(err) + case strings.Contains(err.Error(), "in use"): + err = errdefs.NewConflict(err) + } + return err + } + return nil +} diff --git a/pkg/service/volume/remove_test.go b/pkg/service/volume/remove_test.go new file mode 100644 index 00000000..c9dd47d8 --- /dev/null +++ b/pkg/service/volume/remove_test.go @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "context" + "fmt" + + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/runfinch/finch-daemon/pkg/api/handlers/volume" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/pkg/mocks/mocks_logger" +) + +var _ = Describe("Remove volume API", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + ncClient *mocks_backend.MockNerdctlVolumeSvc + name string + s volume.Service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + ncClient = mocks_backend.NewMockNerdctlVolumeSvc(mockCtrl) + logger := mocks_logger.NewLogger(mockCtrl) + name = "test-volume" + s = NewService(ncClient, logger) + }) + Context("service", func() { + It("should remove the volume successfully", func() { + ncClient.EXPECT().RemoveVolume(ctx, name, false /* force */, gomock.Any()).Return(nil) + err := s.Remove(ctx, name, false) + Expect(err).Should(BeNil()) + }) + It("should return not found error", func() { + // mock mimics not found error occurred in the nerdctl client + ncClient.EXPECT().RemoveVolume(ctx, name, false /* force */, gomock.Any()). + Return(fmt.Errorf("not found")) + err := s.Remove(ctx, name, false) + Expect(errdefs.IsNotFound(err)).Should(BeTrue()) + }) + It("should return in conflict error", func() { + // mock mimics not found error occurred in the nerdctl client + ncClient.EXPECT().RemoveVolume(ctx, name, false /* force */, gomock.Any()). + Return(fmt.Errorf("volume %q is in use", name)) + err := s.Remove(ctx, name, false) + Expect(errdefs.IsConflict(err)).Should(BeTrue()) + }) + It("should return generic error", func() { + // mock mimics not found error occurred in the nerdctl client + ncClient.EXPECT().RemoveVolume(ctx, name, false /* force */, gomock.Any()). + Return(fmt.Errorf("some error")) + err := s.Remove(ctx, name, false) + Expect(errdefs.IsConflict(err)).Should(BeFalse()) + Expect(errdefs.IsNotFound(err)).Should(BeFalse()) + }) + }) +}) diff --git a/pkg/service/volume/volume.go b/pkg/service/volume/volume.go new file mode 100644 index 00000000..ba8df992 --- /dev/null +++ b/pkg/service/volume/volume.go @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// package volume defines the volumes service. +package volume + +import ( + "github.com/runfinch/finch-daemon/pkg/api/handlers/volume" + "github.com/runfinch/finch-daemon/pkg/backend" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +type service struct { + nctlVolumeSvc backend.NerdctlVolumeSvc + logger flog.Logger +} + +func NewService(nerdctlVolumeSvc backend.NerdctlVolumeSvc, logger flog.Logger) volume.Service { + return &service{ + nctlVolumeSvc: nerdctlVolumeSvc, + logger: logger, + } +} diff --git a/pkg/service/volume/volume_test.go b/pkg/service/volume/volume_test.go new file mode 100644 index 00000000..98675dd3 --- /dev/null +++ b/pkg/service/volume/volume_test.go @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package volume + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// TestVolumeService function is the entry point of volume service package's unit test using ginkgo +func TestVolumeService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Volumes APIs Service") +} diff --git a/pkg/statsutil/statsutil.go b/pkg/statsutil/statsutil.go new file mode 100644 index 00000000..be8fc616 --- /dev/null +++ b/pkg/statsutil/statsutil.go @@ -0,0 +1,146 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package statsutil + +import ( + "bufio" + "fmt" + "net" + "os" + "strconv" + "strings" + + "github.com/containerd/nerdctl/pkg/inspecttypes/native" + dockertypes "github.com/docker/docker/api/types" + "github.com/vishvananda/netlink" + "github.com/vishvananda/netns" + "golang.org/x/sys/unix" +) + +//go:generate mockgen --destination=../mocks/mocks_statsutil/statsutil.go -package=mocks_statsutil github.com/runfinch/finch-daemon/pkg/statsutil StatsUtil +type StatsUtil interface { + // GetSystemCPUUsage returns the host system's cpu usage in + // nanoseconds. An error is returned if the format of the underlying + // file does not match. + GetSystemCPUUsage() (uint64, error) + + // GetNumberOnlineCPUs estimates number of available CPUs + GetNumberOnlineCPUs() (uint32, error) + + // CollectNetworkStats collects network usage statistics for specified network + // interfaces in a process namespace + CollectNetworkStats(pid int, interfaces []native.NetInterface) (map[string]dockertypes.NetworkStats, error) +} + +const ( + // From https://github.com/moby/moby/blob/v24.0.2/daemon/stats/collector_unix.go#L20-L21 + clockTicksPerSecond = 100 + nanoSecondsPerSecond = 1e9 +) + +type statsUtil struct{} + +func NewStatsUtil() StatsUtil { + return &statsUtil{} +} + +// GetSystemCPUUsage returns the host system's cpu usage in +// nanoseconds. An error is returned if the format of the underlying +// file does not match. +// +// Uses /proc/stat defined by POSIX. Looks for the cpu +// statistics line and then sums up the first seven fields +// provided. See `man 5 proc` for details on specific field +// information. +// +// Adapted from https://github.com/moby/moby/blob/v24.0.2/daemon/stats/collector_unix.go#L24-L67 +func (s *statsUtil) GetSystemCPUUsage() (uint64, error) { + f, err := os.Open("/proc/stat") + if err != nil { + return 0, err + } + defer f.Close() + bufReader := bufio.NewReader(f) + + for { + line, err := bufReader.ReadString('\n') + if err != nil { + break + } + parts := strings.Fields(line) + switch parts[0] { + case "cpu": + if len(parts) < 8 { + return 0, fmt.Errorf("invalid number of cpu fields") + } + var totalClockTicks uint64 + for _, i := range parts[1:8] { + v, err := strconv.ParseUint(i, 10, 64) + if err != nil { + return 0, fmt.Errorf("Unable to convert value %s to int: %s", i, err) + } + totalClockTicks += v + } + return (totalClockTicks * nanoSecondsPerSecond) / + clockTicksPerSecond, nil + } + } + return 0, fmt.Errorf("invalid stat format. Error trying to parse the '/proc/stat' file") +} + +// Adapted from https://github.com/moby/moby/blob/v24.0.2/daemon/stats/collector_unix.go#L69-L76 +func (s *statsUtil) GetNumberOnlineCPUs() (uint32, error) { + var cpuset unix.CPUSet + err := unix.SchedGetaffinity(0, &cpuset) + if err != nil { + return 0, err + } + return uint32(cpuset.Count()), nil +} + +func (s *statsUtil) CollectNetworkStats(pid int, interfaces []native.NetInterface) (map[string]dockertypes.NetworkStats, error) { + // get network namespace of the process + ns, err := netns.GetFromPid(pid) + if err != nil { + return nil, fmt.Errorf("failed to get network namespace from pid %d: %s", pid, err) + } + defer ns.Close() + nlHandle, err := netlink.NewHandleAt(ns) + if err != nil { + return nil, fmt.Errorf("failed to retrieve the statistics in netns %s: %s", ns, err) + } + defer nlHandle.Close() + + // collect network stats for each network interface + networks := map[string]dockertypes.NetworkStats{} + for _, v := range interfaces { + nlink, err := nlHandle.LinkByIndex(v.Index) + if err != nil { + return nil, fmt.Errorf("failed to retrieve the statistics for %s in netns %s: %s", v.Name, ns, err) + } + //exclude inactive interfaces + if nlink.Attrs().Flags&net.FlagUp != 0 { + //exclude loopback interface + if nlink.Attrs().Flags&net.FlagLoopback != 0 || strings.HasPrefix(nlink.Attrs().Name, "lo") { + continue + } + + net := dockertypes.NetworkStats{} + stats := nlink.Attrs().Statistics + if stats != nil { + net.RxBytes = stats.RxBytes + net.TxBytes = stats.TxBytes + net.RxDropped = stats.RxDropped + net.TxDropped = stats.TxDropped + net.RxErrors = stats.RxErrors + net.TxErrors = stats.TxErrors + net.RxPackets = stats.RxPackets + net.TxPackets = stats.TxPackets + } + networks[v.Name] = net + } + } + + return networks, nil +} diff --git a/pkg/utility/maputility/utility.go b/pkg/utility/maputility/utility.go new file mode 100644 index 00000000..6cf1b3bf --- /dev/null +++ b/pkg/utility/maputility/utility.go @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package maputility + +import "fmt" + +type MapFormatFunction func(string, string) string + +// KeyEqualsValueFormat is a formatting function for formatting a map entry +// like { "key": "value"} into "key=value" when flattening a map. +func KeyEqualsValueFormat(key string, value string) string { + return fmt.Sprintf("%s=%s", key, value) +} + +// Flatten reduces a key-value map into a string array using the provided +// formatting function. +func Flatten(kvMap map[string]string, format MapFormatFunction) []string { + return reduce(kvMap, []string{}, format) +} + +func reduce(collection map[string]string, initial []string, reduce func(string, string) string) []string { + accumulator := initial + + for k, v := range collection { + accumulator = append(accumulator, reduce(k, v)) + } + + return accumulator +} diff --git a/pkg/utility/maputility/utility_test.go b/pkg/utility/maputility/utility_test.go new file mode 100644 index 00000000..4a57d9a2 --- /dev/null +++ b/pkg/utility/maputility/utility_test.go @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package maputility + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMapUtility(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Map Utility Functions") +} + +var _ = Describe("Map Utility", func() { + When("flattening an empty map", func() { + It("should return an empty array", func() { + empty := make(map[string]string, 0) + Expect(Flatten(empty, KeyEqualsValueFormat)).Should(BeEmpty()) + }) + }) + + When("flattening a map with entries", func() { + It("should return an array with key=value format", func() { + mapWithEntries := map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + } + actual := Flatten(mapWithEntries, KeyEqualsValueFormat) + Expect(actual).ShouldNot(BeEmpty()) + Expect(actual).To(ContainElements("key1=value1", "key2=value2", "key3=value3")) + }) + }) +}) diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 00000000..0e9f474a --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package version provides the global default latest API server version for the project +package version + +var ( + // Version and GitCommit value is set from the make file. + Version string + GitCommit string + DefaultApiVersion = "1.43" + MinimumApiVersion = "1.35" +)