diff --git a/cmd/artifact/artifact.go b/cmd/artifact/artifact.go index cd730ade9..d6e6a03a1 100644 --- a/cmd/artifact/artifact.go +++ b/cmd/artifact/artifact.go @@ -20,6 +20,7 @@ import ( "github.com/spf13/cobra" + artifactconfig "github.com/falcosecurity/falcoctl/cmd/artifact/config" "github.com/falcosecurity/falcoctl/cmd/artifact/follow" "github.com/falcosecurity/falcoctl/cmd/artifact/info" "github.com/falcosecurity/falcoctl/cmd/artifact/install" @@ -69,6 +70,7 @@ func NewArtifactCmd(ctx context.Context, opt *commonoptions.Common) *cobra.Comma cmd.AddCommand(list.NewArtifactListCmd(ctx, opt)) cmd.AddCommand(info.NewArtifactInfoCmd(ctx, opt)) cmd.AddCommand(follow.NewArtifactFollowCmd(ctx, opt)) + cmd.AddCommand(artifactconfig.NewArtifactConfigCmd(ctx, opt)) return cmd } diff --git a/cmd/artifact/config/config.go b/cmd/artifact/config/config.go new file mode 100644 index 000000000..dd8a57feb --- /dev/null +++ b/cmd/artifact/config/config.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2023 The Falco Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "context" + + "github.com/spf13/cobra" + + ocipuller "github.com/falcosecurity/falcoctl/pkg/oci/puller" + ociutils "github.com/falcosecurity/falcoctl/pkg/oci/utils" + "github.com/falcosecurity/falcoctl/pkg/options" +) + +type artifactConfigOptions struct { + *options.Common + *options.Registry +} + +// NewArtifactConfigCmd returns the artifact config command. +func NewArtifactConfigCmd(ctx context.Context, opt *options.Common) *cobra.Command { + o := artifactConfigOptions{ + Common: opt, + Registry: &options.Registry{}, + } + + cmd := &cobra.Command{ + Use: "config [ref] [flags]", + Short: "Get the config layer of an artifact", + Long: "Get the config layer of an artifact", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return o.RunArtifactConfig(ctx, args) + }, + } + + o.Registry.AddFlags(cmd) + + return cmd +} + +func (o *artifactConfigOptions) RunArtifactConfig(ctx context.Context, args []string) error { + var ( + puller *ocipuller.Puller + ref string + config []byte + err error + ) + + // Create puller with auto login enabled. + if puller, err = ociutils.Puller(o.PlainHTTP, o.Printer); err != nil { + return err + } + + // Resolve the artifact reference. + if ref, err = o.IndexCache.ResolveReference(args[0]); err != nil { + return err + } + + if config, err = puller.PullConfigLayer(ctx, ref); err != nil { + return err + } + + o.Printer.DefaultText.Println(string(config)) + + return nil +} diff --git a/cmd/artifact/config/config_suite_test.go b/cmd/artifact/config/config_suite_test.go new file mode 100644 index 000000000..ae57d27d4 --- /dev/null +++ b/cmd/artifact/config/config_suite_test.go @@ -0,0 +1,132 @@ +//SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2023 The Falco Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config_test + +import ( + "context" + "fmt" + "testing" + + "github.com/distribution/distribution/v3/configuration" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" + "github.com/spf13/cobra" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + + "github.com/falcosecurity/falcoctl/cmd" + "github.com/falcosecurity/falcoctl/pkg/oci" + "github.com/falcosecurity/falcoctl/pkg/oci/authn" + ocipusher "github.com/falcosecurity/falcoctl/pkg/oci/pusher" + commonoptions "github.com/falcosecurity/falcoctl/pkg/options" + testutils "github.com/falcosecurity/falcoctl/pkg/test" +) + +var ( + localRegistryHost string + localRegistry *remote.Registry + testRuleTarball = "../../../pkg/test/data/rules.tar.gz" + testPluginTarball = "../../../pkg/test/data/plugin.tar.gz" + testPluginPlatform1 = "linux/amd64" + testPluginPlatform2 = "windows/amd64" + testPluginPlatform3 = "linux/arm64" + ctx = context.Background() + pluginMultiPlatformRef string + rulesRef string + artifactWithoutConfigRef string + output = gbytes.NewBuffer() + rootCmd *cobra.Command + opt *commonoptions.Common +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} + +var _ = BeforeSuite(func() { + var err error + config := &configuration.Configuration{} + // Get a free port to be used by the registry. + port, err := testutils.FreePort() + Expect(err).ToNot(HaveOccurred()) + // Create the registry address to which will bind. + config.HTTP.Addr = fmt.Sprintf("localhost:%d", port) + localRegistryHost = config.HTTP.Addr + + // Create the oras registry. + localRegistry, err = testutils.NewOrasRegistry(localRegistryHost, true) + Expect(err).ToNot(HaveOccurred()) + + // Start the local registry. + go func() { + err := testutils.StartRegistry(context.Background(), config) + Expect(err).ToNot(BeNil()) + }() + + // Initialize options for command. + opt = commonoptions.NewOptions() + opt.Initialize(commonoptions.WithWriter(output)) + + // Push the artifacts to the registry. + // Same artifacts will be used to test the puller code. + pusher := ocipusher.NewPusher(authn.NewClient(authn.WithCredentials(&auth.EmptyCredential)), true, nil) + + // Push plugin artifact with multiple architectures. + filePathsAndPlatforms := ocipusher.WithFilepathsAndPlatforms([]string{testPluginTarball, testPluginTarball, testPluginTarball}, + []string{testPluginPlatform1, testPluginPlatform2, testPluginPlatform3}) + pluginMultiPlatformRef = localRegistryHost + "/plugins:multiplatform" + artConfig := oci.ArtifactConfig{} + Expect(artConfig.ParseDependencies("my-dep:1.2.3|my-alt-dep:1.4.5")).ToNot(HaveOccurred()) + Expect(artConfig.ParseRequirements("my-req:7.8.9")).ToNot(HaveOccurred()) + artifactConfig := ocipusher.WithArtifactConfig(artConfig) + + // Build options slice. + options := []ocipusher.Option{filePathsAndPlatforms, artifactConfig} + + // Push the plugin artifact. + _, err = pusher.Push(ctx, oci.Plugin, pluginMultiPlatformRef, options...) + Expect(err).ShouldNot(HaveOccurred()) + + // Prepare and push artifact without config layer. + filePaths := ocipusher.WithFilepaths([]string{testRuleTarball}) + artConfig = oci.ArtifactConfig{} + Expect(artConfig.ParseDependencies("dep1:1.2.3", "dep2:2.3.1")).ToNot(HaveOccurred()) + options = []ocipusher.Option{ + filePaths, + ocipusher.WithTags("latest"), + } + + // Push artifact without config layer. + // Push artifact without config layer. + artifactWithoutConfigRef = localRegistryHost + "/artifact:noconfig" + _, err = pusher.Push(ctx, oci.Rulesfile, artifactWithoutConfigRef, options...) + Expect(err).ShouldNot(HaveOccurred()) + + // Push a rulesfile artifact + options = append(options, ocipusher.WithArtifactConfig(artConfig)) + rulesRef = localRegistryHost + "/rulesfiles:regular" + _, err = pusher.Push(ctx, oci.Rulesfile, rulesRef, options...) + Expect(err).ShouldNot(HaveOccurred()) +}) + +func executeRoot(args []string) error { + rootCmd.SetArgs(args) + rootCmd.SetOut(output) + return cmd.Execute(rootCmd, opt) +} diff --git a/cmd/artifact/config/config_test.go b/cmd/artifact/config/config_test.go new file mode 100644 index 000000000..1eac96dc9 --- /dev/null +++ b/cmd/artifact/config/config_test.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2023 The Falco Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config_test + +import ( + "regexp" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" + + "github.com/falcosecurity/falcoctl/cmd" +) + +var usage = `Usage: + falcoctl artifact config [ref] [flags] + +Flags: + -h, --help help for config + --plain-http allows interacting with remote registry via plain http requests + +Global Flags: + --config string config file to be used for falcoctl (default "/etc/falcoctl/falcoctl.yaml") + --log-format string Set formatting for logs (color, text, json) (default "color") + --log-level string Set level for logs (info, warn, debug, trace) (default "info") +` + +var help = `Get the config layer of an artifact + +Usage: + falcoctl artifact config [ref] [flags] + +Flags: + -h, --help help for config + --plain-http allows interacting with remote registry via plain http requests + +Global Flags: + --config string config file to be used for falcoctl (default "/etc/falcoctl/falcoctl.yaml") + --log-format string Set formatting for logs (color, text, json) (default "color") + --log-level string Set level for logs (info, warn, debug, trace) (default "info")` + +var _ = Describe("Config", func() { + const ( + artifactCmd = "artifact" + configCmd = "config" + plaingHTTP = "--plain-http" + ) + + var ( + err error + args []string + ) + + var assertFailedBehavior = func(usage, specificError string) { + It("check that fails and the usage is not printed", func() { + Expect(err).To(HaveOccurred()) + Expect(output).ShouldNot(gbytes.Say(regexp.QuoteMeta(usage))) + Expect(output).Should(gbytes.Say(regexp.QuoteMeta(specificError))) + }) + } + + JustBeforeEach(func() { + rootCmd = cmd.New(ctx, opt) + err = executeRoot(args) + }) + + JustAfterEach(func() { + err = nil + Expect(output.Clear()).ShouldNot(HaveOccurred()) + args = nil + }) + + Context("help message", func() { + BeforeEach(func() { + args = []string{artifactCmd, configCmd, "--help"} + }) + + It("should match the saved one", func() { + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(output.Contents())).ShouldNot(Equal(help)) + }) + }) + + Context("wrong number of arguments", func() { + When("number of arguments equal to 0", func() { + BeforeEach(func() { + args = []string{artifactCmd, configCmd} + }) + + assertFailedBehavior(usage, "ERROR accepts 1 arg(s), received 0 ") + }) + + When("number of arguments equal to 2", func() { + BeforeEach(func() { + args = []string{artifactCmd, configCmd, "arg1", "arg2"} + }) + + assertFailedBehavior(usage, "ERROR accepts 1 arg(s), received 2 ") + }) + }) + + Context("failure", func() { + When("unreachable/non existing registry", func() { + BeforeEach(func() { + args = []string{artifactCmd, configCmd, "noregistry/noartifact", plaingHTTP} + }) + + assertFailedBehavior(usage, "ERROR unable to fetch reference \"noregistry/noartifact:latest\":"+ + " Get \"http://noregistry/v2/noartifact/manifests/latest\": dial tcp: lookup noregistry: no such host") + }) + + When("non existing repository", func() { + BeforeEach(func() { + args = []string{artifactCmd, configCmd, localRegistryHost + "/noartifact", plaingHTTP} + }) + + assertFailedBehavior(usage, "noartifact:latest: not found") + }) + + When("non parsable reference", func() { + BeforeEach(func() { + args = []string{artifactCmd, configCmd, " ", plaingHTTP} + }) + + assertFailedBehavior(usage, "ERROR cannot find among the configured indexes, skipping ") + }) + }) + + Context("success", func() { + When("empty config layer", func() { + BeforeEach(func() { + args = []string{artifactCmd, configCmd, artifactWithoutConfigRef, plaingHTTP} + }) + + It("should success", func() { + Expect(err).ShouldNot(HaveOccurred()) + Expect(output).Should(gbytes.Say(regexp.QuoteMeta("{}"))) + }) + }) + + When("with valid config layer", func() { + BeforeEach(func() { + args = []string{artifactCmd, configCmd, rulesRef, plaingHTTP} + }) + + It("should success", func() { + Expect(err).ShouldNot(HaveOccurred()) + Expect(output).Should(gbytes.Say(regexp.QuoteMeta(`{"dependencies":[{"name":"dep1","version":"1.2.3"},{"name":"dep2","version":"2.3.1"}]}`))) + }) + }) + }) + +}) diff --git a/cmd/artifact/config/doc.go b/cmd/artifact/config/doc.go new file mode 100644 index 000000000..5cd2594f3 --- /dev/null +++ b/cmd/artifact/config/doc.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2023 The Falco Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package config defines the business logic to fetch config layer for artifacts. +package config