diff --git a/cmd/podman/artifact/add.go b/cmd/podman/artifact/add.go new file mode 100644 index 0000000000..3f3d2fb286 --- /dev/null +++ b/cmd/podman/artifact/add.go @@ -0,0 +1,39 @@ +package artifact + +import ( + "fmt" + + "github.com/containers/podman/v5/cmd/podman/common" + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/spf13/cobra" +) + +var ( + addCmd = &cobra.Command{ + Use: "add ARTIFACT PATH [...PATH]", + Short: "Add an OCI artifact to the local store", + Long: "Add an OCI artifact to the local store from the local filesystem", + RunE: add, + Args: cobra.MinimumNArgs(2), + ValidArgsFunction: common.AutocompleteArtifactAdd, + Example: `podman artifact add quay.io/myimage/myartifact:latest /tmp/foobar.txt`, + Annotations: map[string]string{registry.EngineMode: registry.ABIMode}, + } +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: addCmd, + Parent: artifactCmd, + }) +} + +func add(cmd *cobra.Command, args []string) error { + report, err := registry.ImageEngine().ArtifactAdd(registry.Context(), args[0], args[1:], entities.ArtifactAddoptions{}) + if err != nil { + return err + } + fmt.Println(report.ArtifactDigest.Encoded()) + return nil +} diff --git a/cmd/podman/artifact/artifact.go b/cmd/podman/artifact/artifact.go new file mode 100644 index 0000000000..2b05fd8f12 --- /dev/null +++ b/cmd/podman/artifact/artifact.go @@ -0,0 +1,24 @@ +package artifact + +import ( + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/cmd/podman/validate" + "github.com/spf13/cobra" +) + +var ( + // Command: podman _artifact_ + artifactCmd = &cobra.Command{ + Use: "artifact", + Short: "Manage OCI artifacts", + Long: "Manage OCI artifacts", + RunE: validate.SubCommandExists, + Annotations: map[string]string{registry.EngineMode: registry.ABIMode}, + } +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: artifactCmd, + }) +} diff --git a/cmd/podman/artifact/inspect.go b/cmd/podman/artifact/inspect.go new file mode 100644 index 0000000000..3137d94321 --- /dev/null +++ b/cmd/podman/artifact/inspect.go @@ -0,0 +1,51 @@ +package artifact + +import ( + "github.com/containers/podman/v5/cmd/podman/common" + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/cmd/podman/utils" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/spf13/cobra" +) + +var ( + inspectCmd = &cobra.Command{ + Use: "inspect [ARTIFACT...]", + Short: "Inspect an OCI artifact", + Long: "Provide details on an OCI artifact", + RunE: inspect, + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: common.AutocompleteArtifacts, + Example: `podman artifact inspect quay.io/myimage/myartifact:latest`, + Annotations: map[string]string{registry.EngineMode: registry.ABIMode}, + } +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: inspectCmd, + Parent: artifactCmd, + }) + + // TODO When things firm up on inspect looks, we can do a format implementation + // flags := inspectCmd.Flags() + // formatFlagName := "format" + // flags.StringVar(&inspectFlag.format, formatFlagName, "", "Format volume output using JSON or a Go template") + + // This is something we wanted to do but did not seem important enough for initial PR + // remoteFlagName := "remote" + // flags.BoolVar(&inspectFlag.remote, remoteFlagName, false, "Inspect the image on a container image registry") + + // TODO When the inspect structure has been defined, we need to uncomment and redirect this. Reminder, this + // will also need to be reflected in the podman-artifact-inspect man page + // _ = inspectCmd.RegisterFlagCompletionFunc(formatFlagName, common.AutocompleteFormat(&machine.InspectInfo{})) +} + +func inspect(cmd *cobra.Command, args []string) error { + artifactOptions := entities.ArtifactInspectOptions{} + inspectData, err := registry.ImageEngine().ArtifactInspect(registry.GetContext(), args[0], artifactOptions) + if err != nil { + return err + } + return utils.PrintGenericJSON(inspectData) +} diff --git a/cmd/podman/artifact/list.go b/cmd/podman/artifact/list.go new file mode 100644 index 0000000000..e9ad56542b --- /dev/null +++ b/cmd/podman/artifact/list.go @@ -0,0 +1,134 @@ +package artifact + +import ( + "fmt" + "os" + + "github.com/containers/common/pkg/completion" + "github.com/containers/common/pkg/report" + "github.com/containers/image/v5/docker/reference" + "github.com/containers/podman/v5/cmd/podman/common" + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/cmd/podman/validate" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/docker/go-units" + "github.com/spf13/cobra" +) + +var ( + listCmd = &cobra.Command{ + Use: "ls [options]", + Aliases: []string{"list"}, + Short: "List OCI artifacts", + Long: "List OCI artifacts in local store", + RunE: list, + Args: validate.NoArgs, + ValidArgsFunction: completion.AutocompleteNone, + Example: `podman artifact ls`, + Annotations: map[string]string{registry.EngineMode: registry.ABIMode}, + } + listFlag = listFlagType{} +) + +type listFlagType struct { + format string +} + +type artifactListOutput struct { + Digest string + Repository string + Size string + Tag string +} + +var ( + defaultArtifactListOutputFormat = "{{range .}}{{.Repository}}\t{{.Tag}}\t{{.Digest}}\t{{.Size}}\n{{end -}}" +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: listCmd, + Parent: artifactCmd, + }) + flags := listCmd.Flags() + formatFlagName := "format" + flags.StringVar(&listFlag.format, formatFlagName, defaultArtifactListOutputFormat, "Format volume output using JSON or a Go template") + _ = listCmd.RegisterFlagCompletionFunc(formatFlagName, common.AutocompleteFormat(&artifactListOutput{})) +} + +func list(cmd *cobra.Command, _ []string) error { + reports, err := registry.ImageEngine().ArtifactList(registry.GetContext(), entities.ArtifactListOptions{}) + if err != nil { + return err + } + + return outputTemplate(cmd, reports) +} + +func outputTemplate(cmd *cobra.Command, lrs []*entities.ArtifactListReport) error { + var err error + artifacts := make([]artifactListOutput, 0) + for _, lr := range lrs { + var ( + tag string + ) + artifactName, err := lr.Artifact.GetName() + if err != nil { + return err + } + repo, err := reference.Parse(artifactName) + if err != nil { + return err + } + named, ok := repo.(reference.Named) + if !ok { + return fmt.Errorf("%q is an invalid artifact name", artifactName) + } + if tagged, ok := named.(reference.Tagged); ok { + tag = tagged.Tag() + } + + // Note: Right now we only support things that are single manifests + // We should certainly expand this support for things like arch, etc + // as we move on + artifactDigest, err := lr.Artifact.GetDigest() + if err != nil { + return err + } + // TODO when we default to shorter ids, i would foresee a switch + // like images that will show the full ids. + artifacts = append(artifacts, artifactListOutput{ + Digest: artifactDigest.Encoded(), + Repository: named.Name(), + Size: units.HumanSize(float64(lr.Artifact.TotalSizeBytes())), + Tag: tag, + }) + } + + headers := report.Headers(artifactListOutput{}, map[string]string{ + "REPOSITORY": "REPOSITORY", + "Tag": "TAG", + "Size": "SIZE", + "Digest": "DIGEST", + }) + + rpt := report.New(os.Stdout, cmd.Name()) + defer rpt.Flush() + + switch { + case cmd.Flag("format").Changed: + rpt, err = rpt.Parse(report.OriginUser, listFlag.format) + default: + rpt, err = rpt.Parse(report.OriginPodman, listFlag.format) + } + if err != nil { + return err + } + + if rpt.RenderHeaders { + if err := rpt.Execute(headers); err != nil { + return fmt.Errorf("failed to write report column headers: %w", err) + } + } + return rpt.Execute(artifacts) +} diff --git a/cmd/podman/artifact/pull.go b/cmd/podman/artifact/pull.go new file mode 100644 index 0000000000..12d78c46fb --- /dev/null +++ b/cmd/podman/artifact/pull.go @@ -0,0 +1,162 @@ +package artifact + +import ( + "fmt" + "os" + + "github.com/containers/buildah/pkg/cli" + "github.com/containers/common/pkg/auth" + "github.com/containers/common/pkg/completion" + "github.com/containers/image/v5/types" + "github.com/containers/podman/v5/cmd/podman/common" + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/containers/podman/v5/pkg/util" + "github.com/spf13/cobra" +) + +// pullOptionsWrapper wraps entities.ImagePullOptions and prevents leaking +// CLI-only fields into the API types. +type pullOptionsWrapper struct { + entities.ArtifactPullOptions + TLSVerifyCLI bool // CLI only + CredentialsCLI string + DecryptionKeys []string +} + +var ( + pullOptions = pullOptionsWrapper{} + pullDescription = `Pulls an artifact from a registry and stores it locally.` + + pullCmd = &cobra.Command{ + Use: "pull [options] ARTIFACT", + Short: "Pull an OCI artifact", + Long: pullDescription, + RunE: artifactPull, + Args: cobra.ExactArgs(1), + ValidArgsFunction: common.AutocompleteArtifacts, + Example: `podman artifact pull quay.io/myimage/myartifact:latest`, + Annotations: map[string]string{registry.EngineMode: registry.ABIMode}, + } +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: pullCmd, + Parent: artifactCmd, + }) + pullFlags(pullCmd) +} + +// pullFlags set the flags for the pull command. +func pullFlags(cmd *cobra.Command) { + flags := cmd.Flags() + + credsFlagName := "creds" + flags.StringVar(&pullOptions.CredentialsCLI, credsFlagName, "", "`Credentials` (USERNAME:PASSWORD) to use for authenticating to a registry") + _ = cmd.RegisterFlagCompletionFunc(credsFlagName, completion.AutocompleteNone) + + flags.BoolVarP(&pullOptions.Quiet, "quiet", "q", false, "Suppress output information when pulling images") + flags.BoolVar(&pullOptions.TLSVerifyCLI, "tls-verify", true, "Require HTTPS and verify certificates when contacting registries") + + authfileFlagName := "authfile" + flags.StringVar(&pullOptions.AuthFilePath, authfileFlagName, auth.GetDefaultAuthFile(), "Path of the authentication file. Use REGISTRY_AUTH_FILE environment variable to override") + _ = cmd.RegisterFlagCompletionFunc(authfileFlagName, completion.AutocompleteDefault) + + decryptionKeysFlagName := "decryption-key" + flags.StringArrayVar(&pullOptions.DecryptionKeys, decryptionKeysFlagName, nil, "Key needed to decrypt the image (e.g. /path/to/key.pem)") + _ = cmd.RegisterFlagCompletionFunc(decryptionKeysFlagName, completion.AutocompleteDefault) + + retryFlagName := "retry" + flags.Uint(retryFlagName, registry.RetryDefault(), "number of times to retry in case of failure when performing pull") + _ = cmd.RegisterFlagCompletionFunc(retryFlagName, completion.AutocompleteNone) + retryDelayFlagName := "retry-delay" + flags.String(retryDelayFlagName, registry.RetryDelayDefault(), "delay between retries in case of pull failures") + _ = cmd.RegisterFlagCompletionFunc(retryDelayFlagName, completion.AutocompleteNone) + + if registry.IsRemote() { + _ = flags.MarkHidden(decryptionKeysFlagName) + } else { + certDirFlagName := "cert-dir" + flags.StringVar(&pullOptions.CertDirPath, certDirFlagName, "", "`Pathname` of a directory containing TLS certificates and keys") + _ = cmd.RegisterFlagCompletionFunc(certDirFlagName, completion.AutocompleteDefault) + } +} + +func artifactPull(cmd *cobra.Command, args []string) error { + // TLS verification in c/image is controlled via a `types.OptionalBool` + // which allows for distinguishing among set-true, set-false, unspecified + // which is important to implement a sane way of dealing with defaults of + // boolean CLI flags. + if cmd.Flags().Changed("tls-verify") { + pullOptions.InsecureSkipTLSVerify = types.NewOptionalBool(!pullOptions.TLSVerifyCLI) + } + + if cmd.Flags().Changed("retry") { + retry, err := cmd.Flags().GetUint("retry") + if err != nil { + return err + } + + pullOptions.MaxRetries = &retry + } + + if cmd.Flags().Changed("retry-delay") { + val, err := cmd.Flags().GetString("retry-delay") + if err != nil { + return err + } + + pullOptions.RetryDelay = val + } + + if cmd.Flags().Changed("authfile") { + if err := auth.CheckAuthFile(pullOptions.AuthFilePath); err != nil { + return err + } + } + + // TODO Once we have a decision about the flag removal above, this should be safe to delete + /* + platform, err := cmd.Flags().GetString("platform") + if err != nil { + return err + } + if platform != "" { + if pullOptions.Arch != "" || pullOptions.OS != "" { + return errors.New("--platform option can not be specified with --arch or --os") + } + + specs := strings.Split(platform, "/") + pullOptions.OS = specs[0] // may be empty + if len(specs) > 1 { + pullOptions.Arch = specs[1] + if len(specs) > 2 { + pullOptions.Variant = specs[2] + } + } + } + */ + + if pullOptions.CredentialsCLI != "" { + creds, err := util.ParseRegistryCreds(pullOptions.CredentialsCLI) + if err != nil { + return err + } + pullOptions.Username = creds.Username + pullOptions.Password = creds.Password + } + + decConfig, err := cli.DecryptConfig(pullOptions.DecryptionKeys) + if err != nil { + return fmt.Errorf("unable to obtain decryption config: %w", err) + } + pullOptions.OciDecryptConfig = decConfig + + if !pullOptions.Quiet { + pullOptions.Writer = os.Stdout + } + + _, err = registry.ImageEngine().ArtifactPull(registry.GetContext(), args[0], pullOptions.ArtifactPullOptions) + return err +} diff --git a/cmd/podman/artifact/push.go b/cmd/podman/artifact/push.go new file mode 100644 index 0000000000..e5bd68dffc --- /dev/null +++ b/cmd/podman/artifact/push.go @@ -0,0 +1,229 @@ +package artifact + +import ( + "fmt" + "os" + + "github.com/containers/buildah/pkg/cli" + "github.com/containers/common/pkg/auth" + "github.com/containers/common/pkg/completion" + "github.com/containers/image/v5/types" + "github.com/containers/podman/v5/cmd/podman/common" + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/containers/podman/v5/pkg/util" + "github.com/spf13/cobra" +) + +// pushOptionsWrapper wraps entities.ImagepushOptions and prevents leaking +// CLI-only fields into the API types. +type pushOptionsWrapper struct { + entities.ArtifactPushOptions + TLSVerifyCLI bool // CLI only + CredentialsCLI string + SignPassphraseFileCLI string + SignBySigstoreParamFileCLI string + EncryptionKeys []string + EncryptLayers []int + DigestFile string +} + +var ( + pushOptions = pushOptionsWrapper{} + pushDescription = `Push an OCI artifact from local storage to an image registry` + + pushCmd = &cobra.Command{ + Use: "push [options] ARTIFACT.", + Short: "Push an OCI artifact", + Long: pushDescription, + RunE: artifactPush, + Args: cobra.ExactArgs(1), + ValidArgsFunction: common.AutocompleteArtifacts, + Example: `podman artifact push quay.io/myimage/myartifact:latest`, + Annotations: map[string]string{registry.EngineMode: registry.ABIMode}, + } +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: pushCmd, + Parent: artifactCmd, + }) + pushFlags(pushCmd) +} + +// pullFlags set the flags for the pull command. +func pushFlags(cmd *cobra.Command) { + flags := cmd.Flags() + + // For now default All flag to true, for pushing of manifest lists + pushOptions.All = true + authfileFlagName := "authfile" + flags.StringVar(&pushOptions.Authfile, authfileFlagName, auth.GetDefaultAuthFile(), "Path of the authentication file. Use REGISTRY_AUTH_FILE environment variable to override") + _ = cmd.RegisterFlagCompletionFunc(authfileFlagName, completion.AutocompleteDefault) + + certDirFlagName := "cert-dir" + flags.StringVar(&pushOptions.CertDir, certDirFlagName, "", "Path to a directory containing TLS certificates and keys") + _ = cmd.RegisterFlagCompletionFunc(certDirFlagName, completion.AutocompleteDefault) + + // This is a flag I didn't wire up but could be considered + // flags.BoolVar(&pushOptions.Compress, "compress", false, "Compress tarball image layers when pushing to a directory using the 'dir' transport. (default is same compression type as source)") + + credsFlagName := "creds" + flags.StringVar(&pushOptions.CredentialsCLI, credsFlagName, "", "`Credentials` (USERNAME:PASSWORD) to use for authenticating to a registry") + _ = cmd.RegisterFlagCompletionFunc(credsFlagName, completion.AutocompleteNone) + + digestfileFlagName := "digestfile" + flags.StringVar(&pushOptions.DigestFile, digestfileFlagName, "", "Write the digest of the pushed image to the specified file") + _ = cmd.RegisterFlagCompletionFunc(digestfileFlagName, completion.AutocompleteDefault) + + flags.BoolVarP(&pushOptions.Quiet, "quiet", "q", false, "Suppress output information when pushing images") + + retryFlagName := "retry" + flags.Uint(retryFlagName, registry.RetryDefault(), "number of times to retry in case of failure when performing push") + _ = cmd.RegisterFlagCompletionFunc(retryFlagName, completion.AutocompleteNone) + + retryDelayFlagName := "retry-delay" + flags.String(retryDelayFlagName, registry.RetryDelayDefault(), "delay between retries in case of push failures") + _ = cmd.RegisterFlagCompletionFunc(retryDelayFlagName, completion.AutocompleteNone) + + signByFlagName := "sign-by" + flags.StringVar(&pushOptions.SignBy, signByFlagName, "", "Add a signature at the destination using the specified key") + _ = cmd.RegisterFlagCompletionFunc(signByFlagName, completion.AutocompleteNone) + + signBySigstoreFlagName := "sign-by-sigstore" + flags.StringVar(&pushOptions.SignBySigstoreParamFileCLI, signBySigstoreFlagName, "", "Sign the image using a sigstore parameter file at `PATH`") + _ = cmd.RegisterFlagCompletionFunc(signBySigstoreFlagName, completion.AutocompleteDefault) + + signBySigstorePrivateKeyFlagName := "sign-by-sigstore-private-key" + flags.StringVar(&pushOptions.SignBySigstorePrivateKeyFile, signBySigstorePrivateKeyFlagName, "", "Sign the image using a sigstore private key at `PATH`") + _ = cmd.RegisterFlagCompletionFunc(signBySigstorePrivateKeyFlagName, completion.AutocompleteDefault) + + signPassphraseFileFlagName := "sign-passphrase-file" + flags.StringVar(&pushOptions.SignPassphraseFileCLI, signPassphraseFileFlagName, "", "Read a passphrase for signing an image from `PATH`") + _ = cmd.RegisterFlagCompletionFunc(signPassphraseFileFlagName, completion.AutocompleteDefault) + + flags.BoolVar(&pushOptions.TLSVerifyCLI, "tls-verify", true, "Require HTTPS and verify certificates when contacting registries") + + // TODO I think these two can be removed? + /* + compFormat := "compression-format" + flags.StringVar(&pushOptions.CompressionFormat, compFormat, compressionFormat(), "compression format to use") + _ = cmd.RegisterFlagCompletionFunc(compFormat, common.AutocompleteCompressionFormat) + + compLevel := "compression-level" + flags.Int(compLevel, compressionLevel(), "compression level to use") + _ = cmd.RegisterFlagCompletionFunc(compLevel, completion.AutocompleteNone) + + */ + + // Potential options that could be wired up if deemed necessary + // encryptionKeysFlagName := "encryption-key" + // flags.StringArrayVar(&pushOptions.EncryptionKeys, encryptionKeysFlagName, nil, "Key with the encryption protocol to use to encrypt the image (e.g. jwe:/path/to/key.pem)") + // _ = cmd.RegisterFlagCompletionFunc(encryptionKeysFlagName, completion.AutocompleteDefault) + + // encryptLayersFlagName := "encrypt-layer" + // flags.IntSliceVar(&pushOptions.EncryptLayers, encryptLayersFlagName, nil, "Layers to encrypt, 0-indexed layer indices with support for negative indexing (e.g. 0 is the first layer, -1 is the last layer). If not defined, will encrypt all layers if encryption-key flag is specified") + // _ = cmd.RegisterFlagCompletionFunc(encryptLayersFlagName, completion.AutocompleteDefault) + + if registry.IsRemote() { + _ = flags.MarkHidden("cert-dir") + _ = flags.MarkHidden("compress") + _ = flags.MarkHidden("quiet") + _ = flags.MarkHidden(signByFlagName) + _ = flags.MarkHidden(signBySigstoreFlagName) + _ = flags.MarkHidden(signBySigstorePrivateKeyFlagName) + _ = flags.MarkHidden(signPassphraseFileFlagName) + } else { + signaturePolicyFlagName := "signature-policy" + flags.StringVar(&pushOptions.SignaturePolicy, signaturePolicyFlagName, "", "Path to a signature-policy file") + _ = flags.MarkHidden(signaturePolicyFlagName) + } +} + +func artifactPush(cmd *cobra.Command, args []string) error { + source := args[0] + // Should we just make destination == origin ? + // destination := args[len(args)-1] + + // TLS verification in c/image is controlled via a `types.OptionalBool` + // which allows for distinguishing among set-true, set-false, unspecified + // which is important to implement a sane way of dealing with defaults of + // boolean CLI flags. + if cmd.Flags().Changed("tls-verify") { + pushOptions.SkipTLSVerify = types.NewOptionalBool(!pushOptions.TLSVerifyCLI) + } + + if cmd.Flags().Changed("authfile") { + if err := auth.CheckAuthFile(pushOptions.Authfile); err != nil { + return err + } + } + + if pushOptions.CredentialsCLI != "" { + creds, err := util.ParseRegistryCreds(pushOptions.CredentialsCLI) + if err != nil { + return err + } + pushOptions.Username = creds.Username + pushOptions.Password = creds.Password + } + + if !pushOptions.Quiet { + pushOptions.Writer = os.Stderr + } + + signingCleanup, err := common.PrepareSigning(&pushOptions.ImagePushOptions, + pushOptions.SignPassphraseFileCLI, pushOptions.SignBySigstoreParamFileCLI) + if err != nil { + return err + } + defer signingCleanup() + + encConfig, encLayers, err := cli.EncryptConfig(pushOptions.EncryptionKeys, pushOptions.EncryptLayers) + if err != nil { + return fmt.Errorf("unable to obtain encryption config: %w", err) + } + pushOptions.OciEncryptConfig = encConfig + pushOptions.OciEncryptLayers = encLayers + + if cmd.Flags().Changed("retry") { + retry, err := cmd.Flags().GetUint("retry") + if err != nil { + return err + } + + pushOptions.Retry = &retry + } + + if cmd.Flags().Changed("retry-delay") { + val, err := cmd.Flags().GetString("retry-delay") + if err != nil { + return err + } + + pushOptions.RetryDelay = val + } + + // TODO If not compression options are supported, we do not need the following + /* + if cmd.Flags().Changed("compression-level") { + val, err := cmd.Flags().GetInt("compression-level") + if err != nil { + return err + } + pushOptions.CompressionLevel = &val + } + + if cmd.Flags().Changed("compression-format") { + if !cmd.Flags().Changed("force-compression") { + // If `compression-format` is set and no value for `--force-compression` + // is selected then defaults to `true`. + pushOptions.ForceCompressionFormat = true + } + } + */ + + _, err = registry.ImageEngine().ArtifactPush(registry.GetContext(), source, pushOptions.ArtifactPushOptions) + return err +} diff --git a/cmd/podman/artifact/rm.go b/cmd/podman/artifact/rm.go new file mode 100644 index 0000000000..8ff6bc1bf7 --- /dev/null +++ b/cmd/podman/artifact/rm.go @@ -0,0 +1,50 @@ +package artifact + +import ( + "fmt" + + "github.com/containers/podman/v5/cmd/podman/common" + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/spf13/cobra" +) + +var ( + rmCmd = &cobra.Command{ + Use: "rm ARTIFACT", + Short: "Remove an OCI artifact", + Long: "Remove an OCI from local storage", + RunE: rm, + Aliases: []string{"remove"}, + Args: cobra.ExactArgs(1), + ValidArgsFunction: common.AutocompleteArtifacts, + Example: `podman artifact rm quay.io/myimage/myartifact:latest`, + Annotations: map[string]string{registry.EngineMode: registry.ABIMode}, + } + // The lint avoid here is because someday soon we will need flags for + // this command + rmFlag = rmFlagType{} //nolint:unused +) + +// TODO at some point force will be a required option; but this cannot be +// until we have artifacts being consumed by other parts of libpod like +// volumes +type rmFlagType struct { //nolint:unused + force bool +} + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: rmCmd, + Parent: artifactCmd, + }) +} + +func rm(cmd *cobra.Command, args []string) error { + artifactRemoveReport, err := registry.ImageEngine().ArtifactRm(registry.GetContext(), args[0], entities.ArtifactRemoveOptions{}) + if err != nil { + return err + } + fmt.Println(artifactRemoveReport.ArtfactDigest.Encoded()) + return nil +} diff --git a/cmd/podman/common/completion.go b/cmd/podman/common/completion.go index e46675733c..3476f33be8 100644 --- a/cmd/podman/common/completion.go +++ b/cmd/podman/common/completion.go @@ -317,6 +317,29 @@ func getNetworks(cmd *cobra.Command, toComplete string, cType completeType) ([]s return suggestions, cobra.ShellCompDirectiveNoFileComp } +func getArtifacts(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + suggestions := []string{} + listOptions := entities.ArtifactListOptions{} + + engine, err := setupImageEngine(cmd) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveNoFileComp + } + artifacts, err := engine.ArtifactList(registry.GetContext(), listOptions) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveNoFileComp + } + + for _, artifact := range artifacts { + if strings.HasPrefix(artifact.Name, toComplete) { + suggestions = append(suggestions, artifact.Name) + } + } + return suggestions, cobra.ShellCompDirectiveNoFileComp +} + func fdIsNotDir(f *os.File) bool { stat, err := f.Stat() if err != nil { @@ -493,6 +516,24 @@ func getBoolCompletion(_ string) ([]string, cobra.ShellCompDirective) { /* Autocomplete Functions for cobra ValidArgsFunction */ +func AutocompleteArtifacts(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if !validCurrentCmdLine(cmd, args, toComplete) { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getArtifacts(cmd, toComplete) +} + +func AutocompleteArtifactAdd(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if !validCurrentCmdLine(cmd, args, toComplete) { + return nil, cobra.ShellCompDirectiveNoFileComp + } + if len(args) == 0 { + // first argument accepts the name reference + return getArtifacts(cmd, toComplete) + } + return nil, cobra.ShellCompDirectiveDefault +} + // AutocompleteContainers - Autocomplete all container names. func AutocompleteContainers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if !validCurrentCmdLine(cmd, args, toComplete) { diff --git a/cmd/podman/inspect/inspect.go b/cmd/podman/inspect/inspect.go index 69864071aa..7fd5db8195 100644 --- a/cmd/podman/inspect/inspect.go +++ b/cmd/podman/inspect/inspect.go @@ -2,7 +2,6 @@ package inspect import ( "context" - "encoding/json" // due to a bug in json-iterator it cannot be used here "errors" "fmt" "os" @@ -12,6 +11,7 @@ import ( "github.com/containers/common/pkg/report" "github.com/containers/podman/v5/cmd/podman/common" "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/cmd/podman/utils" "github.com/containers/podman/v5/cmd/podman/validate" "github.com/containers/podman/v5/pkg/domain/entities" "github.com/spf13/cobra" @@ -163,7 +163,7 @@ func (i *inspector) inspect(namesOrIDs []string) error { var err error switch { case report.IsJSON(i.options.Format) || i.options.Format == "": - err = printJSON(data) + err = utils.PrintGenericJSON(data) default: // Landing here implies user has given a custom --format var rpt *report.Formatter @@ -191,15 +191,6 @@ func (i *inspector) inspect(namesOrIDs []string) error { return nil } -func printJSON(data interface{}) error { - enc := json.NewEncoder(os.Stdout) - // by default, json marshallers will force utf=8 from - // a string. this breaks healthchecks that use <,>, &&. - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - return enc.Encode(data) -} - func (i *inspector) inspectAll(ctx context.Context, namesOrIDs []string) ([]interface{}, []error, error) { var data []interface{} allErrs := []error{} diff --git a/cmd/podman/main.go b/cmd/podman/main.go index dd5b984d8d..309a421529 100644 --- a/cmd/podman/main.go +++ b/cmd/podman/main.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + _ "github.com/containers/podman/v5/cmd/podman/artifact" _ "github.com/containers/podman/v5/cmd/podman/completion" _ "github.com/containers/podman/v5/cmd/podman/farm" _ "github.com/containers/podman/v5/cmd/podman/generate" diff --git a/cmd/podman/utils/utils.go b/cmd/podman/utils/utils.go index 3a33967a24..5f4baae5c7 100644 --- a/cmd/podman/utils/utils.go +++ b/cmd/podman/utils/utils.go @@ -2,6 +2,7 @@ package utils import ( "context" + "encoding/json" "fmt" "os" "strings" @@ -148,3 +149,12 @@ func RemoveSlash(input []string) []string { } return output } + +func PrintGenericJSON(data interface{}) error { + enc := json.NewEncoder(os.Stdout) + // by default, json marshallers will force utf=8 from + // a string. + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(data) +} diff --git a/docs/source/Commands.rst b/docs/source/Commands.rst index 6a81adfa89..29a41b8e33 100644 --- a/docs/source/Commands.rst +++ b/docs/source/Commands.rst @@ -5,6 +5,8 @@ Commands :doc:`Podman ` (Pod Manager) Global Options, Environment Variables, Exit Codes, Configuration Files, and more +:doc:`artifact ` Manage OCI artifacts + :doc:`attach ` Attach to a running container :doc:`auto-update ` Auto update containers according to their auto-update policy diff --git a/docs/source/markdown/.gitignore b/docs/source/markdown/.gitignore index 7cef2f7d80..370b2e81c9 100644 --- a/docs/source/markdown/.gitignore +++ b/docs/source/markdown/.gitignore @@ -1,3 +1,5 @@ +podman-artifact-pull.1.md +podman-artifact-push.1.md podman-attach.1.md podman-auto-update.1.md podman-build.1.md diff --git a/docs/source/markdown/options/authfile.md b/docs/source/markdown/options/authfile.md index 005573b1a9..e6724b4b8a 100644 --- a/docs/source/markdown/options/authfile.md +++ b/docs/source/markdown/options/authfile.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman auto update, build, container runlabel, create, farm build, image sign, kube play, login, logout, manifest add, manifest inspect, manifest push, pull, push, run, search +####> podman artifact pull, artifact push, auto update, build, container runlabel, create, farm build, image sign, kube play, login, logout, manifest add, manifest inspect, manifest push, pull, push, run, search ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--authfile**=*path* diff --git a/docs/source/markdown/options/cert-dir.md b/docs/source/markdown/options/cert-dir.md index dbb008a2d0..014b1cd452 100644 --- a/docs/source/markdown/options/cert-dir.md +++ b/docs/source/markdown/options/cert-dir.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, container runlabel, farm build, image sign, kube play, login, manifest add, manifest push, pull, push, search +####> podman artifact pull, artifact push, build, container runlabel, farm build, image sign, kube play, login, manifest add, manifest push, pull, push, search ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--cert-dir**=*path* diff --git a/docs/source/markdown/options/creds.md b/docs/source/markdown/options/creds.md index 910895c20e..76bdfb5035 100644 --- a/docs/source/markdown/options/creds.md +++ b/docs/source/markdown/options/creds.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, container runlabel, farm build, kube play, manifest add, manifest push, pull, push, search +####> podman artifact pull, artifact push, build, container runlabel, farm build, kube play, manifest add, manifest push, pull, push, search ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--creds**=*[username[:password]]* diff --git a/docs/source/markdown/options/decryption-key.md b/docs/source/markdown/options/decryption-key.md index 67d6535086..ee7ed881bc 100644 --- a/docs/source/markdown/options/decryption-key.md +++ b/docs/source/markdown/options/decryption-key.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, create, farm build, pull, run +####> podman artifact pull, build, create, farm build, pull, run ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--decryption-key**=*key[:passphrase]* diff --git a/docs/source/markdown/options/digestfile.md b/docs/source/markdown/options/digestfile.md index 64166b94ff..d479337a79 100644 --- a/docs/source/markdown/options/digestfile.md +++ b/docs/source/markdown/options/digestfile.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman manifest push, push +####> podman artifact push, manifest push, push ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--digestfile**=*Digestfile* diff --git a/docs/source/markdown/options/retry-delay.md b/docs/source/markdown/options/retry-delay.md index 005f471e4a..14c652ffb1 100644 --- a/docs/source/markdown/options/retry-delay.md +++ b/docs/source/markdown/options/retry-delay.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, create, farm build, pull, push, run +####> podman artifact pull, artifact push, build, create, farm build, pull, push, run ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--retry-delay**=*duration* diff --git a/docs/source/markdown/options/retry.md b/docs/source/markdown/options/retry.md index 0abca43aa0..08c8c263e3 100644 --- a/docs/source/markdown/options/retry.md +++ b/docs/source/markdown/options/retry.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, create, farm build, pull, push, run +####> podman artifact pull, artifact push, build, create, farm build, pull, push, run ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--retry**=*attempts* diff --git a/docs/source/markdown/options/sign-by-sigstore.md b/docs/source/markdown/options/sign-by-sigstore.md index 3ab4d7f0b4..78919daf28 100644 --- a/docs/source/markdown/options/sign-by-sigstore.md +++ b/docs/source/markdown/options/sign-by-sigstore.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman manifest push, push +####> podman artifact push, manifest push, push ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--sign-by-sigstore**=*param-file* diff --git a/docs/source/markdown/options/sign-passphrase-file.md b/docs/source/markdown/options/sign-passphrase-file.md index c3b272d444..f25233ac1d 100644 --- a/docs/source/markdown/options/sign-passphrase-file.md +++ b/docs/source/markdown/options/sign-passphrase-file.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman manifest push, push +####> podman artifact push, manifest push, push ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--sign-passphrase-file**=*path* diff --git a/docs/source/markdown/options/tls-verify.md b/docs/source/markdown/options/tls-verify.md index 62f1e3609c..c789a53058 100644 --- a/docs/source/markdown/options/tls-verify.md +++ b/docs/source/markdown/options/tls-verify.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman auto update, build, container runlabel, create, farm build, kube play, login, manifest add, manifest create, manifest inspect, manifest push, pull, push, run, search +####> podman artifact pull, artifact push, auto update, build, container runlabel, create, farm build, kube play, login, manifest add, manifest create, manifest inspect, manifest push, pull, push, run, search ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--tls-verify** diff --git a/docs/source/markdown/podman-artifact-add.1.md b/docs/source/markdown/podman-artifact-add.1.md new file mode 100644 index 0000000000..6ad757a3e4 --- /dev/null +++ b/docs/source/markdown/podman-artifact-add.1.md @@ -0,0 +1,48 @@ +% podman-artifact-add 1 + +## WARNING: Experimental command +*This command is considered experimental and still in development. Inputs, options, and outputs are all +subject to change.* + +## NAME +podman\-artifact\-add - Add an OCI artifact to the local store + +## SYNOPSIS +**podman artifact add** *name* *file* [*file*]... + +## DESCRIPTION + +Add an OCI artifact to the local store from the local filesystem. You must +provide at least one file to create the artifact, but several can also be +added. + + +## OPTIONS + +#### **--help** + +Print usage statement. + + +## EXAMPLES + +Add a single file to an artifact + +``` +$ podman artifact add quay.io/myartifact/myml:latest /tmp/foobar.ml +0fe1488ecdef8cc4093e11a55bc048d9fc3e13a4ba846efd24b5a715006c95b3 +``` + +Add multiple files to an artifact +``` +$ podman artifact add quay.io/myartifact/myml:latest /tmp/foobar1.ml /tmp/foobar2.ml +1487acae11b5a30948c50762882036b41ac91a7b9514be8012d98015c95ddb78 +``` + + + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[podman-artifact(1)](podman-artifact.1.md)** + +## HISTORY +Jan 2025, Originally compiled by Brent Baude diff --git a/docs/source/markdown/podman-artifact-inspect.1.md b/docs/source/markdown/podman-artifact-inspect.1.md new file mode 100644 index 0000000000..7d4b21920f --- /dev/null +++ b/docs/source/markdown/podman-artifact-inspect.1.md @@ -0,0 +1,38 @@ +% podman-artifact-inspect 1 + + +## WARNING: Experimental command +*This command is considered experimental and still in development. Inputs, options, and outputs are all +subject to change.* + +## NAME +podman\-artifact\-inspect - Inspect an OCI artifact + +## SYNOPSIS +**podman artifact inspect** [*name*] ... + +## DESCRIPTION + +Inspect an artifact in the local store. The artifact can be referred to with either: + +1. Fully qualified artifact name +2. Full or partial digest of the artifact's manifest + +## OPTIONS + +#### **--help** + +Print usage statement. + +## EXAMPLES + +Inspect an OCI image in the local store. +``` +$ podman artifact inspect quay.io/myartifact/myml:latest +``` + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[podman-artifact(1)](podman-artifact.1.md)** + +## HISTORY +Sept 2024, Originally compiled by Brent Baude diff --git a/docs/source/markdown/podman-artifact-ls.1.md b/docs/source/markdown/podman-artifact-ls.1.md new file mode 100644 index 0000000000..720c1dec36 --- /dev/null +++ b/docs/source/markdown/podman-artifact-ls.1.md @@ -0,0 +1,57 @@ +% podman-artifact-ls 1 + + +## WARNING: Experimental command +*This command is considered experimental and still in development. Inputs, options, and outputs are all +subject to change.* + +## NAME +podman\-artifact\-ls - List OCI artifacts in local store + +## SYNOPSIS +**podman artifact ls** [*options*] + +## DESCRIPTION + +List all local artifacts in your local store. + +## OPTIONS + +#### **--format** + +Print results with a Go template. + +| **Placeholder** | **Description** | +|-----------------|------------------------------------------------| +| .Digest | The computed digest of the artifact's manifest | +| .Repository | Repository name of the artifact | +| .Size | Size artifact in human readable units | +| .Tag | Tag of the artifact name | + + + +## EXAMPLES + +List artifacts in the local store +``` +$ podman artifact ls +REPOSITORY TAG DIGEST SIZE +quay.io/artifact/foobar1 latest ab609fad386df1433f461b0643d9cf575560baf633809dcc9c190da6cc3a3c29 2.097GB +quay.io/artifact/foobar2 special cd734b558ceb8ccc0281ca76530e1dea1eb479407d3163f75fb601bffb6f73d0 12.58MB + + +``` +List artifact digests and size using a --format +``` +$ podman artifact ls --format "{{.Digest}} {{.Size}}" +ab609fad386df1433f461b0643d9cf575560baf633809dcc9c190da6cc3a3c29 2.097GB +cd734b558ceb8ccc0281ca76530e1dea1eb479407d3163f75fb601bffb6f73d0 12.58MB +``` + + + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[podman-artifact(1)](podman-artifact.1.md)** + +## HISTORY +Jan 2025, Originally compiled by Brent Baude diff --git a/docs/source/markdown/podman-artifact-pull.1.md.in b/docs/source/markdown/podman-artifact-pull.1.md.in new file mode 100644 index 0000000000..4733cb8ddb --- /dev/null +++ b/docs/source/markdown/podman-artifact-pull.1.md.in @@ -0,0 +1,75 @@ +% podman-artifact-pull 1 + + +## WARNING: Experimental command +*This command is considered experimental and still in development. Inputs, options, and outputs are all +subject to change.* + +## NAME +podman\-artifact\-pull - Pulls an artifact from a registry and stores it locally + +## SYNOPSIS +**podman artifact pull** [*options*] *source* + + +## DESCRIPTION +podman artifact pull copies an artifact from a registry onto the local machine. + + +## SOURCE +SOURCE is the location from which the artifact image is obtained. + +``` +# Pull from a registry +$ podman artifact pull quay.io/foobar/artifact:special +``` + +## OPTIONS + +@@option authfile + +@@option cert-dir + +@@option creds + +@@option decryption-key + + +#### **--help**, **-h** + +Print the usage statement. + +#### **--quiet**, **-q** + +Suppress output information when pulling images + +@@option retry + +@@option retry-delay + +@@option tls-verify + +## FILES + +## EXAMPLES +Pull an artifact from a registry + +``` +podman artifact pull quay.io/baude/artifact:josey +Getting image source signatures +Copying blob e741c35a27bb done | +Copying config 44136fa355 done | +Writing manifest to image destination + +``` + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[podman-artifact(1)](podman-artifact.1.md)**, **[podman-login(1)](podman-login.1.md)**, **[containers-certs.d(5)](https://github.com/containers/image/blob/main/docs/containers-certs.d.5.md)** + +### Troubleshooting + +See [podman-troubleshooting(7)](https://github.com/containers/podman/blob/main/troubleshooting.md) +for solutions to common issues. + +## HISTORY +Jan 2025, Originally compiled by Brent Baude diff --git a/docs/source/markdown/podman-artifact-push.1.md.in b/docs/source/markdown/podman-artifact-push.1.md.in new file mode 100644 index 0000000000..e304152965 --- /dev/null +++ b/docs/source/markdown/podman-artifact-push.1.md.in @@ -0,0 +1,71 @@ +% podman-artifact-push 1 + + +## WARNING: Experimental command +*This command is considered experimental and still in development. Inputs, options, and outputs are all +subject to change.* + +## NAME +podman\-artifact\-push - Push an OCI artifact from local storage to an image registry + +## SYNOPSIS +**podman artifact push** [*options*] *image* + +## DESCRIPTION +Pushes an artifact from the local artifact store to an image registry. + +``` +# Push artifact to a container registry +$ podman artifact push quay.io/artifact/foobar1:latest +``` + +## OPTIONS + +@@option authfile + +@@option cert-dir + +@@option creds + +@@option digestfile + +#### **--quiet**, **-q** + +When writing the output image, suppress progress output + +@@option retry + +@@option retry-delay + +#### **--sign-by**=*key* + +Add a “simple signing” signature at the destination using the specified key. (This option is not available with the remote Podman client, including Mac and Windows (excluding WSL2) machines) + +@@option sign-by-sigstore + + +#### **--sign-by-sigstore-private-key**=*path* + +Add a sigstore signature at the destination using a private key at the specified path. (This option is not available with the remote Podman client, including Mac and Windows (excluding WSL2) machines) + +@@option sign-passphrase-file + +@@option tls-verify + +## EXAMPLE + +Push the specified iage to a container registry: +``` +$ podman artifact push quay.io/baude/artifact:single +Getting image source signatures +Copying blob 3ddc0a3cdb61 done | +Copying config 44136fa355 done | +Writing manifest to image destination +``` + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[podman-artifact(1)](podman-artifact.1.md)**, **[podman-pull(1)](podman-pull.1.md)**, **[podman-login(1)](podman-login.1.md)**, **[containers-certs.d(5)](https://github.com/containers/image/blob/main/docs/containers-certs.d.5.md)** + + +## HISTORY +Jan 2025, Originally compiled by Brent Baude diff --git a/docs/source/markdown/podman-artifact-rm.1.md b/docs/source/markdown/podman-artifact-rm.1.md new file mode 100644 index 0000000000..207c416cf3 --- /dev/null +++ b/docs/source/markdown/podman-artifact-rm.1.md @@ -0,0 +1,46 @@ +% podman-artifact-rm 1 + + +## WARNING: Experimental command +*This command is considered experimental and still in development. Inputs, options, and outputs are all +subject to change.* + +## NAME +podman\-artifact\-rm - Remove an OCI from local storage + +## SYNOPSIS +**podman artifact rm** *name* + +## DESCRIPTION + +Remove an artifact from the local artifact store. The input may be the fully +qualified artifact name or a full or partial artifact digest. + +## OPTIONS + +#### **--help** + +Print usage statement. + + +## EXAMPLES + +Remove an artifact by name + +``` +$ podman artifact rm quay.io/artifact/foobar2:test +e7b417f49fc24fc7ead6485da0ebd5bc4419d8a3f394c169fee5a6f38faa4056 +``` + +Remove an artifact by partial digest + +``` +$ podman artifact rm e7b417f49fc +e7b417f49fc24fc7ead6485da0ebd5bc4419d8a3f394c169fee5a6f38faa4056 +``` + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[podman-artifact(1)](podman-artifact.1.md)** + +## HISTORY +Jan 2025, Originally compiled by Brent Baude diff --git a/docs/source/markdown/podman-artifact.1.md b/docs/source/markdown/podman-artifact.1.md new file mode 100644 index 0000000000..8597464edb --- /dev/null +++ b/docs/source/markdown/podman-artifact.1.md @@ -0,0 +1,36 @@ +% podman-artifact 1 + +## WARNING: Experimental command +*This command is considered experimental and still in development. Inputs, options, and outputs are all +subject to change.* + +## NAME +podman\-artifact - Manage OCI artifacts + +## SYNOPSIS +**podman artifact** *subcommand* + +## DESCRIPTION +`podman artifact` is a set of subcommands that manage OCI artifacts. + +OCI artifacts are a common way to distribute files that are associated with OCI images and +containers. Podman is capable of managing (pulling, inspecting, pushing) these artifacts +from its local "artifact store". + +## SUBCOMMANDS + +| Command | Man Page | Description | +|---------|------------------------------------------------------------|--------------------------------------------------------------| +| add | [podman-artifact-add(1)](podman-artifact-add.1.md) | Add an OCI artifact to the local store | +| inspect | [podman-artifact-inspect(1)](podman-artifact-inspect.1.md) | Inspect an OCI artifact | +| ls | [podman-artifact-ls(1)](podman-artifact-ls.1.md) | List OCI artifacts in local store | +| pull | [podman-artifact-pull(1)](podman-artifact-pull.1.md) | Pulls an artifact from a registry and stores it locally | +| push | [podman-artifact-push(1)](podman-artifact-push.1.md) | Push an OCI artifact from local storage to an image registry | +| rm | [podman-artifact-rm(1)](podman-artifact-rm.1.md) | Remove an OCI from local storage | + + +## SEE ALSO +**[podman(1)](podman.1.md)** + +## HISTORY +Sept 2024, Originally compiled by Brent Baude diff --git a/docs/source/markdown/podman.1.md b/docs/source/markdown/podman.1.md index 5484080618..e57245d84a 100644 --- a/docs/source/markdown/podman.1.md +++ b/docs/source/markdown/podman.1.md @@ -325,69 +325,70 @@ the exit codes follow the `chroot` standard, see below: ## COMMANDS -| Command | Description | -| ------------------------------------------------ | --------------------------------------------------------------------------- | -| [podman-attach(1)](podman-attach.1.md) | Attach to a running container. | -| [podman-auto-update(1)](podman-auto-update.1.md) | Auto update containers according to their auto-update policy | -| [podman-build(1)](podman-build.1.md) | Build a container image using a Containerfile. | -| [podman-farm(1)](podman-farm.1.md) | Farm out builds to machines running podman for different architectures | -| [podman-commit(1)](podman-commit.1.md) | Create new image based on the changed container. | -| [podman-completion(1)](podman-completion.1.md) | Generate shell completion scripts | -| [podman-compose(1)](podman-compose.1.md) | Run Compose workloads via an external compose provider. | -| [podman-container(1)](podman-container.1.md) | Manage containers. | -| [podman-cp(1)](podman-cp.1.md) | Copy files/folders between a container and the local filesystem. | -| [podman-create(1)](podman-create.1.md) | Create a new container. | -| [podman-diff(1)](podman-diff.1.md) | Inspect changes on a container or image's filesystem. | -| [podman-events(1)](podman-events.1.md) | Monitor Podman events | -| [podman-exec(1)](podman-exec.1.md) | Execute a command in a running container. | -| [podman-export(1)](podman-export.1.md) | Export a container's filesystem contents as a tar archive. | -| [podman-generate(1)](podman-generate.1.md) | Generate structured data based on containers, pods or volumes. | -| [podman-healthcheck(1)](podman-healthcheck.1.md) | Manage healthchecks for containers | -| [podman-history(1)](podman-history.1.md) | Show the history of an image. | -| [podman-image(1)](podman-image.1.md) | Manage images. | -| [podman-images(1)](podman-images.1.md) | List images in local storage. | -| [podman-import(1)](podman-import.1.md) | Import a tarball and save it as a filesystem image. | -| [podman-info(1)](podman-info.1.md) | Display Podman related system information. | -| [podman-init(1)](podman-init.1.md) | Initialize one or more containers | -| [podman-inspect(1)](podman-inspect.1.md) | Display a container, image, volume, network, or pod's configuration. | -| [podman-kill(1)](podman-kill.1.md) | Kill the main process in one or more containers. | -| [podman-load(1)](podman-load.1.md) | Load image(s) from a tar archive into container storage. | -| [podman-login(1)](podman-login.1.md) | Log in to a container registry. | -| [podman-logout(1)](podman-logout.1.md) | Log out of a container registry. | -| [podman-logs(1)](podman-logs.1.md) | Display the logs of one or more containers. | -| [podman-machine(1)](podman-machine.1.md) | Manage Podman's virtual machine | -| [podman-manifest(1)](podman-manifest.1.md) | Create and manipulate manifest lists and image indexes. | -| [podman-mount(1)](podman-mount.1.md) | Mount a working container's root filesystem. | -| [podman-network(1)](podman-network.1.md) | Manage Podman networks. | -| [podman-pause(1)](podman-pause.1.md) | Pause one or more containers. | -| [podman-kube(1)](podman-kube.1.md) | Play containers, pods or volumes based on a structured input file. | -| [podman-pod(1)](podman-pod.1.md) | Management tool for groups of containers, called pods. | -| [podman-port(1)](podman-port.1.md) | List port mappings for a container. | -| [podman-ps(1)](podman-ps.1.md) | Print out information about containers. | -| [podman-pull(1)](podman-pull.1.md) | Pull an image from a registry. | -| [podman-push(1)](podman-push.1.md) | Push an image, manifest list or image index from local storage to elsewhere.| -| [podman-rename(1)](podman-rename.1.md) | Rename an existing container. | -| [podman-restart(1)](podman-restart.1.md) | Restart one or more containers. | -| [podman-rm(1)](podman-rm.1.md) | Remove one or more containers. | -| [podman-rmi(1)](podman-rmi.1.md) | Remove one or more locally stored images. | -| [podman-run(1)](podman-run.1.md) | Run a command in a new container. | -| [podman-save(1)](podman-save.1.md) | Save image(s) to an archive. | -| [podman-search(1)](podman-search.1.md) | Search a registry for an image. | -| [podman-secret(1)](podman-secret.1.md) | Manage podman secrets. | -| [podman-start(1)](podman-start.1.md) | Start one or more containers. | -| [podman-stats(1)](podman-stats.1.md) | Display a live stream of one or more container's resource usage statistics. | -| [podman-stop(1)](podman-stop.1.md) | Stop one or more running containers. | -| [podman-system(1)](podman-system.1.md) | Manage podman. | -| [podman-tag(1)](podman-tag.1.md) | Add an additional name to a local image. | -| [podman-top(1)](podman-top.1.md) | Display the running processes of a container. | -| [podman-unmount(1)](podman-unmount.1.md) | Unmount a working container's root filesystem. | -| [podman-unpause(1)](podman-unpause.1.md) | Unpause one or more containers. | -| [podman-unshare(1)](podman-unshare.1.md) | Run a command inside of a modified user namespace. | -| [podman-untag(1)](podman-untag.1.md) | Remove one or more names from a locally-stored image. | -| [podman-update(1)](podman-update.1.md) | Update the configuration of a given container. | -| [podman-version(1)](podman-version.1.md) | Display the Podman version information. | -| [podman-volume(1)](podman-volume.1.md) | Simple management tool for volumes. | -| [podman-wait(1)](podman-wait.1.md) | Wait on one or more containers to stop and print their exit codes. | +| Command | Description | +|--------------------------------------------------|------------------------------------------------------------------------------| +| [podman-artifact(1)](podman-artifact.1.md) | Manage OCI artifacts. | +| [podman-attach(1)](podman-attach.1.md) | Attach to a running container. | +| [podman-auto-update(1)](podman-auto-update.1.md) | Auto update containers according to their auto-update policy | +| [podman-build(1)](podman-build.1.md) | Build a container image using a Containerfile. | +| [podman-farm(1)](podman-farm.1.md) | Farm out builds to machines running podman for different architectures | +| [podman-commit(1)](podman-commit.1.md) | Create new image based on the changed container. | +| [podman-completion(1)](podman-completion.1.md) | Generate shell completion scripts | +| [podman-compose(1)](podman-compose.1.md) | Run Compose workloads via an external compose provider. | +| [podman-container(1)](podman-container.1.md) | Manage containers. | +| [podman-cp(1)](podman-cp.1.md) | Copy files/folders between a container and the local filesystem. | +| [podman-create(1)](podman-create.1.md) | Create a new container. | +| [podman-diff(1)](podman-diff.1.md) | Inspect changes on a container or image's filesystem. | +| [podman-events(1)](podman-events.1.md) | Monitor Podman events | +| [podman-exec(1)](podman-exec.1.md) | Execute a command in a running container. | +| [podman-export(1)](podman-export.1.md) | Export a container's filesystem contents as a tar archive. | +| [podman-generate(1)](podman-generate.1.md) | Generate structured data based on containers, pods or volumes. | +| [podman-healthcheck(1)](podman-healthcheck.1.md) | Manage healthchecks for containers | +| [podman-history(1)](podman-history.1.md) | Show the history of an image. | +| [podman-image(1)](podman-image.1.md) | Manage images. | +| [podman-images(1)](podman-images.1.md) | List images in local storage. | +| [podman-import(1)](podman-import.1.md) | Import a tarball and save it as a filesystem image. | +| [podman-info(1)](podman-info.1.md) | Display Podman related system information. | +| [podman-init(1)](podman-init.1.md) | Initialize one or more containers | +| [podman-inspect(1)](podman-inspect.1.md) | Display a container, image, volume, network, or pod's configuration. | +| [podman-kill(1)](podman-kill.1.md) | Kill the main process in one or more containers. | +| [podman-load(1)](podman-load.1.md) | Load image(s) from a tar archive into container storage. | +| [podman-login(1)](podman-login.1.md) | Log in to a container registry. | +| [podman-logout(1)](podman-logout.1.md) | Log out of a container registry. | +| [podman-logs(1)](podman-logs.1.md) | Display the logs of one or more containers. | +| [podman-machine(1)](podman-machine.1.md) | Manage Podman's virtual machine | +| [podman-manifest(1)](podman-manifest.1.md) | Create and manipulate manifest lists and image indexes. | +| [podman-mount(1)](podman-mount.1.md) | Mount a working container's root filesystem. | +| [podman-network(1)](podman-network.1.md) | Manage Podman networks. | +| [podman-pause(1)](podman-pause.1.md) | Pause one or more containers. | +| [podman-kube(1)](podman-kube.1.md) | Play containers, pods or volumes based on a structured input file. | +| [podman-pod(1)](podman-pod.1.md) | Management tool for groups of containers, called pods. | +| [podman-port(1)](podman-port.1.md) | List port mappings for a container. | +| [podman-ps(1)](podman-ps.1.md) | Print out information about containers. | +| [podman-pull(1)](podman-pull.1.md) | Pull an image from a registry. | +| [podman-push(1)](podman-push.1.md) | Push an image, manifest list or image index from local storage to elsewhere. | +| [podman-rename(1)](podman-rename.1.md) | Rename an existing container. | +| [podman-restart(1)](podman-restart.1.md) | Restart one or more containers. | +| [podman-rm(1)](podman-rm.1.md) | Remove one or more containers. | +| [podman-rmi(1)](podman-rmi.1.md) | Remove one or more locally stored images. | +| [podman-run(1)](podman-run.1.md) | Run a command in a new container. | +| [podman-save(1)](podman-save.1.md) | Save image(s) to an archive. | +| [podman-search(1)](podman-search.1.md) | Search a registry for an image. | +| [podman-secret(1)](podman-secret.1.md) | Manage podman secrets. | +| [podman-start(1)](podman-start.1.md) | Start one or more containers. | +| [podman-stats(1)](podman-stats.1.md) | Display a live stream of one or more container's resource usage statistics. | +| [podman-stop(1)](podman-stop.1.md) | Stop one or more running containers. | +| [podman-system(1)](podman-system.1.md) | Manage podman. | +| [podman-tag(1)](podman-tag.1.md) | Add an additional name to a local image. | +| [podman-top(1)](podman-top.1.md) | Display the running processes of a container. | +| [podman-unmount(1)](podman-unmount.1.md) | Unmount a working container's root filesystem. | +| [podman-unpause(1)](podman-unpause.1.md) | Unpause one or more containers. | +| [podman-unshare(1)](podman-unshare.1.md) | Run a command inside of a modified user namespace. | +| [podman-untag(1)](podman-untag.1.md) | Remove one or more names from a locally-stored image. | +| [podman-update(1)](podman-update.1.md) | Update the configuration of a given container. | +| [podman-version(1)](podman-version.1.md) | Display the Podman version information. | +| [podman-volume(1)](podman-volume.1.md) | Simple management tool for volumes. | +| [podman-wait(1)](podman-wait.1.md) | Wait on one or more containers to stop and print their exit codes. | ## CONFIGURATION FILES diff --git a/pkg/domain/entities/artifact.go b/pkg/domain/entities/artifact.go new file mode 100644 index 0000000000..29f788a594 --- /dev/null +++ b/pkg/domain/entities/artifact.go @@ -0,0 +1,72 @@ +package entities + +import ( + "io" + + "github.com/containers/image/v5/types" + encconfig "github.com/containers/ocicrypt/config" + "github.com/containers/podman/v5/pkg/libartifact" + "github.com/opencontainers/go-digest" +) + +type ArtifactAddoptions struct { + ArtifactType string +} + +type ArtifactInspectOptions struct { + Remote bool +} + +type ArtifactListOptions struct { + ImagePushOptions +} + +type ArtifactPullOptions struct { + Architecture string + AuthFilePath string + CertDirPath string + InsecureSkipTLSVerify types.OptionalBool + MaxRetries *uint + OciDecryptConfig *encconfig.DecryptConfig + Password string + Quiet bool + RetryDelay string + SignaturePolicyPath string + Username string + Writer io.Writer +} + +type ArtifactPushOptions struct { + ImagePushOptions + CredentialsCLI string + DigestFile string + EncryptLayers []int + EncryptionKeys []string + SignBySigstoreParamFileCLI string + SignPassphraseFileCLI string + TLSVerifyCLI bool // CLI only +} + +type ArtifactRemoveOptions struct { +} + +type ArtifactPullReport struct{} + +type ArtifactPushReport struct{} + +type ArtifactInspectReport struct { + *libartifact.Artifact + Digest string +} + +type ArtifactListReport struct { + *libartifact.Artifact +} + +type ArtifactAddReport struct { + ArtifactDigest *digest.Digest +} + +type ArtifactRemoveReport struct { + ArtfactDigest *digest.Digest +} diff --git a/pkg/domain/entities/engine_image.go b/pkg/domain/entities/engine_image.go index 65844f676f..65c8cd8150 100644 --- a/pkg/domain/entities/engine_image.go +++ b/pkg/domain/entities/engine_image.go @@ -9,6 +9,12 @@ import ( ) type ImageEngine interface { //nolint:interfacebloat + ArtifactAdd(ctx context.Context, name string, paths []string, opts ArtifactAddoptions) (*ArtifactAddReport, error) + ArtifactInspect(ctx context.Context, name string, opts ArtifactInspectOptions) (*ArtifactInspectReport, error) + ArtifactList(ctx context.Context, opts ArtifactListOptions) ([]*ArtifactListReport, error) + ArtifactPull(ctx context.Context, name string, opts ArtifactPullOptions) (*ArtifactPullReport, error) + ArtifactPush(ctx context.Context, name string, opts ArtifactPushOptions) (*ArtifactPushReport, error) + ArtifactRm(ctx context.Context, name string, opts ArtifactRemoveOptions) (*ArtifactRemoveReport, error) Build(ctx context.Context, containerFiles []string, opts BuildOptions) (*BuildReport, error) Config(ctx context.Context) (*config.Config, error) Exists(ctx context.Context, nameOrID string) (*BoolReport, error) diff --git a/pkg/domain/infra/abi/artifact.go b/pkg/domain/infra/abi/artifact.go new file mode 100644 index 0000000000..126db79e26 --- /dev/null +++ b/pkg/domain/infra/abi/artifact.go @@ -0,0 +1,167 @@ +//go:build !remote + +package abi + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/containers/common/libimage" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/containers/podman/v5/pkg/libartifact/store" +) + +func getDefaultArtifactStore(ir *ImageEngine) string { + return filepath.Join(ir.Libpod.StorageConfig().GraphRoot, "artifacts") +} + +func (ir *ImageEngine) ArtifactInspect(ctx context.Context, name string, _ entities.ArtifactInspectOptions) (*entities.ArtifactInspectReport, error) { + artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext()) + if err != nil { + return nil, err + } + art, err := artStore.Inspect(ctx, name) + if err != nil { + return nil, err + } + artDigest, err := art.GetDigest() + if err != nil { + return nil, err + } + artInspectReport := entities.ArtifactInspectReport{ + Artifact: art, + Digest: artDigest.String(), + } + return &artInspectReport, nil +} + +func (ir *ImageEngine) ArtifactList(ctx context.Context, _ entities.ArtifactListOptions) ([]*entities.ArtifactListReport, error) { + reports := make([]*entities.ArtifactListReport, 0) + artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext()) + if err != nil { + return nil, err + } + lrs, err := artStore.List(ctx) + if err != nil { + return nil, err + } + for _, lr := range lrs { + artListReport := entities.ArtifactListReport{ + Artifact: lr, + } + reports = append(reports, &artListReport) + } + return reports, nil +} + +func (ir *ImageEngine) ArtifactPull(ctx context.Context, name string, opts entities.ArtifactPullOptions) (*entities.ArtifactPullReport, error) { + pullOptions := &libimage.CopyOptions{} + pullOptions.AuthFilePath = opts.AuthFilePath + pullOptions.CertDirPath = opts.CertDirPath + pullOptions.Username = opts.Username + pullOptions.Password = opts.Password + // pullOptions.Architecture = opts.Arch + pullOptions.SignaturePolicyPath = opts.SignaturePolicyPath + pullOptions.InsecureSkipTLSVerify = opts.InsecureSkipTLSVerify + pullOptions.Writer = opts.Writer + pullOptions.OciDecryptConfig = opts.OciDecryptConfig + pullOptions.MaxRetries = opts.MaxRetries + + if !opts.Quiet && pullOptions.Writer == nil { + pullOptions.Writer = os.Stderr + } + artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext()) + if err != nil { + return nil, err + } + return nil, artStore.Pull(ctx, name, *pullOptions) +} + +func (ir *ImageEngine) ArtifactRm(ctx context.Context, name string, _ entities.ArtifactRemoveOptions) (*entities.ArtifactRemoveReport, error) { + artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext()) + if err != nil { + return nil, err + } + artifactDigest, err := artStore.Remove(ctx, name) + if err != nil { + return nil, err + } + artifactRemoveReport := entities.ArtifactRemoveReport{ + ArtfactDigest: artifactDigest, + } + return &artifactRemoveReport, err +} + +func (ir *ImageEngine) ArtifactPush(ctx context.Context, name string, opts entities.ArtifactPushOptions) (*entities.ArtifactPushReport, error) { + var retryDelay *time.Duration + + artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext()) + if err != nil { + return nil, err + } + + if opts.RetryDelay != "" { + rd, err := time.ParseDuration(opts.RetryDelay) + if err != nil { + return nil, err + } + retryDelay = &rd + } + + copyOpts := libimage.CopyOptions{ + SystemContext: nil, + SourceLookupReferenceFunc: nil, + DestinationLookupReferenceFunc: nil, + CompressionFormat: nil, + CompressionLevel: nil, + ForceCompressionFormat: false, + AuthFilePath: opts.Authfile, + BlobInfoCacheDirPath: "", + CertDirPath: opts.CertDir, + DirForceCompress: false, + ImageListSelection: 0, + InsecureSkipTLSVerify: opts.SkipTLSVerify, + MaxRetries: opts.Retry, + RetryDelay: retryDelay, + ManifestMIMEType: "", + OciAcceptUncompressedLayers: false, + OciEncryptConfig: nil, + OciEncryptLayers: opts.OciEncryptLayers, + OciDecryptConfig: nil, + Progress: nil, + PolicyAllowStorage: false, + SignaturePolicyPath: opts.SignaturePolicy, + Signers: opts.Signers, + SignBy: opts.SignBy, + SignPassphrase: opts.SignPassphrase, + SignBySigstorePrivateKeyFile: opts.SignBySigstorePrivateKeyFile, + SignSigstorePrivateKeyPassphrase: opts.SignSigstorePrivateKeyPassphrase, + RemoveSignatures: opts.RemoveSignatures, + Architecture: "", + OS: "", + Variant: "", + Username: "", + Password: "", + Credentials: opts.CredentialsCLI, + IdentityToken: "", + Writer: opts.Writer, + } + + err = artStore.Push(ctx, name, name, copyOpts) + return &entities.ArtifactPushReport{}, err +} +func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []string, opts entities.ArtifactAddoptions) (*entities.ArtifactAddReport, error) { + artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext()) + if err != nil { + return nil, err + } + artifactDigest, err := artStore.Add(ctx, name, paths, opts.ArtifactType) + if err != nil { + return nil, err + } + return &entities.ArtifactAddReport{ + ArtifactDigest: artifactDigest, + }, nil +} diff --git a/pkg/domain/infra/tunnel/artifact.go b/pkg/domain/infra/tunnel/artifact.go new file mode 100644 index 0000000000..99657707dd --- /dev/null +++ b/pkg/domain/infra/tunnel/artifact.go @@ -0,0 +1,38 @@ +package tunnel + +import ( + "context" + "fmt" + + "github.com/containers/podman/v5/pkg/domain/entities" +) + +// TODO For now, no remote support has been added. We need the API to firm up first. + +func ArtifactAdd(ctx context.Context, path, name string, opts entities.ArtifactAddoptions) error { + return fmt.Errorf("not implemented") +} + +func (ir *ImageEngine) ArtifactInspect(ctx context.Context, name string, opts entities.ArtifactInspectOptions) (*entities.ArtifactInspectReport, error) { + return nil, fmt.Errorf("not implemented") +} + +func (ir *ImageEngine) ArtifactList(ctx context.Context, opts entities.ArtifactListOptions) ([]*entities.ArtifactListReport, error) { + return nil, fmt.Errorf("not implemented") +} + +func (ir *ImageEngine) ArtifactPull(ctx context.Context, name string, opts entities.ArtifactPullOptions) (*entities.ArtifactPullReport, error) { + return nil, fmt.Errorf("not implemented") +} + +func (ir *ImageEngine) ArtifactRm(ctx context.Context, name string, opts entities.ArtifactRemoveOptions) (*entities.ArtifactRemoveReport, error) { + return nil, fmt.Errorf("not implemented") +} + +func (ir *ImageEngine) ArtifactPush(ctx context.Context, name string, opts entities.ArtifactPushOptions) (*entities.ArtifactPushReport, error) { + return nil, fmt.Errorf("not implemented") +} + +func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []string, opts entities.ArtifactAddoptions) (*entities.ArtifactAddReport, error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/pkg/libartifact/artifact.go b/pkg/libartifact/artifact.go new file mode 100644 index 0000000000..326ea72f2c --- /dev/null +++ b/pkg/libartifact/artifact.go @@ -0,0 +1,89 @@ +package libartifact + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/containers/image/v5/manifest" + "github.com/opencontainers/go-digest" +) + +type Artifact struct { + Manifests []manifest.OCI1 + Name string +} + +// TotalSizeBytes returns the total bytes of the all the artifact layers +func (a *Artifact) TotalSizeBytes() int64 { + var s int64 + for _, artifact := range a.Manifests { + for _, layer := range artifact.Layers { + s += layer.Size + } + } + return s +} + +// GetName returns the "name" or "image reference" of the artifact +func (a *Artifact) GetName() (string, error) { + if a.Name != "" { + return a.Name, nil + } + // We don't have a concept of None for artifacts yet, but if we do, + // then we should probably not error but return `None` + return "", errors.New("artifact is unnamed") +} + +// SetName is a accessor for setting the artifact name +// Note: long term this may not be needed, and we would +// be comfortable with simply using the exported field +// called Name +func (a *Artifact) SetName(name string) { + a.Name = name +} + +func (a *Artifact) GetDigest() (*digest.Digest, error) { + if len(a.Manifests) > 1 { + return nil, fmt.Errorf("not supported: multiple manifests found in artifact") + } + if len(a.Manifests) < 1 { + return nil, fmt.Errorf("not supported: no manifests found in artifact") + } + b, err := json.Marshal(a.Manifests[0]) + if err != nil { + return nil, err + } + artifactDigest := digest.FromBytes(b) + return &artifactDigest, nil +} + +type ArtifactList []*Artifact + +// GetByNameOrDigest returns an artifact, if present, by a given name +// Returns an error if not found +func (al ArtifactList) GetByNameOrDigest(nameOrDigest string) (*Artifact, bool, error) { + // This is the hot route through + for _, artifact := range al { + if artifact.Name == nameOrDigest { + return artifact, false, nil + } + } + // Before giving up, check by digest + for _, artifact := range al { + // TODO Here we have to assume only a single manifest for the artifact; this will + // need to evolve + if len(artifact.Manifests) > 0 { + artifactDigest, err := artifact.GetDigest() + if err != nil { + return nil, false, err + } + // If the artifact's digest matches or is a prefix of ... + if artifactDigest.Encoded() == nameOrDigest || strings.HasPrefix(artifactDigest.Encoded(), nameOrDigest) { + return artifact, true, nil + } + } + } + return nil, false, fmt.Errorf("no artifact found with name or digest of %s", nameOrDigest) +} diff --git a/pkg/libartifact/store/config.go b/pkg/libartifact/store/config.go new file mode 100644 index 0000000000..5613e18554 --- /dev/null +++ b/pkg/libartifact/store/config.go @@ -0,0 +1,41 @@ +//go:build !remote + +package store + +import ( + "context" + "encoding/json" + + "github.com/containers/image/v5/types" + specV1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +// unparsedArtifactImage is an interface based on the UnParsedImage and +// is used only for the commit of the manifest +type unparsedArtifactImage struct { + ir types.ImageReference + mannyfest specV1.Manifest +} + +func (u unparsedArtifactImage) Reference() types.ImageReference { + return u.ir +} + +func (u unparsedArtifactImage) Manifest(ctx context.Context) ([]byte, string, error) { + b, err := json.Marshal(u.mannyfest) + if err != nil { + return nil, "", err + } + return b, specV1.MediaTypeImageIndex, nil +} + +func (u unparsedArtifactImage) Signatures(ctx context.Context) ([][]byte, error) { + return [][]byte{}, nil +} + +func newUnparsedArtifactImage(ir types.ImageReference, mannyfest specV1.Manifest) unparsedArtifactImage { + return unparsedArtifactImage{ + ir: ir, + mannyfest: mannyfest, + } +} diff --git a/pkg/libartifact/store/store.go b/pkg/libartifact/store/store.go new file mode 100644 index 0000000000..c33e17d9ef --- /dev/null +++ b/pkg/libartifact/store/store.go @@ -0,0 +1,366 @@ +//go:build !remote + +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/containers/common/libimage" + "github.com/containers/image/v5/manifest" + "github.com/containers/image/v5/oci/layout" + "github.com/containers/image/v5/transports/alltransports" + "github.com/containers/image/v5/types" + "github.com/containers/podman/v5/pkg/libartifact" + libartTypes "github.com/containers/podman/v5/pkg/libartifact/types" + "github.com/containers/storage/pkg/fileutils" + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" + specV1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" +) + +var ( + ErrEmptyArtifactName = errors.New("artifact name cannot be empty") +) + +type ArtifactStore struct { + SystemContext *types.SystemContext + storePath string +} + +// NewArtifactStore is a constructor for artifact stores. Most artifact dealings depend on this. Store path is +// the filesystem location. +func NewArtifactStore(storePath string, sc *types.SystemContext) (*ArtifactStore, error) { + if storePath == "" { + return nil, errors.New("store path cannot be empty") + } + logrus.Debugf("Using artifact store path: %s", storePath) + + artifactStore := &ArtifactStore{ + storePath: storePath, + SystemContext: sc, + } + + // if the storage dir does not exist, we need to create it. + baseDir := filepath.Dir(artifactStore.indexPath()) + if err := os.MkdirAll(baseDir, 0700); err != nil { + return nil, err + } + // if the index file is not present we need to create an empty one + if err := fileutils.Exists(artifactStore.indexPath()); err != nil && errors.Is(err, os.ErrNotExist) { + if createErr := artifactStore.createEmptyManifest(); createErr != nil { + return nil, createErr + } + } + return artifactStore, nil +} + +// Remove an artifact from the local artifact store +func (as ArtifactStore) Remove(ctx context.Context, name string) (*digest.Digest, error) { + if len(name) == 0 { + return nil, ErrEmptyArtifactName + } + + // validate and see if the input is a digest + artifacts, err := as.getArtifacts(ctx, nil) + if err != nil { + return nil, err + } + + arty, nameIsDigest, err := artifacts.GetByNameOrDigest(name) + if err != nil { + return nil, err + } + if nameIsDigest { + name = arty.Name + } + ir, err := layout.NewReference(as.storePath, name) + if err != nil { + return nil, err + } + artifactDigest, err := arty.GetDigest() + if err != nil { + return nil, err + } + return artifactDigest, ir.DeleteImage(ctx, as.SystemContext) +} + +// Inspect an artifact in a local store +func (as ArtifactStore) Inspect(ctx context.Context, nameOrDigest string) (*libartifact.Artifact, error) { + if len(nameOrDigest) == 0 { + return nil, ErrEmptyArtifactName + } + artifacts, err := as.getArtifacts(ctx, nil) + if err != nil { + return nil, err + } + inspectData, _, err := artifacts.GetByNameOrDigest(nameOrDigest) + return inspectData, err +} + +// List artifacts in the local store +func (as ArtifactStore) List(ctx context.Context) (libartifact.ArtifactList, error) { + return as.getArtifacts(ctx, nil) +} + +// Pull an artifact from an image registry to a local store +func (as ArtifactStore) Pull(ctx context.Context, name string, opts libimage.CopyOptions) error { + if len(name) == 0 { + return ErrEmptyArtifactName + } + srcRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", name)) + if err != nil { + return err + } + destRef, err := layout.NewReference(as.storePath, name) + if err != nil { + return err + } + copyer, err := libimage.NewCopier(&opts, as.SystemContext, nil) + if err != nil { + return err + } + _, err = copyer.Copy(ctx, srcRef, destRef) + if err != nil { + return err + } + return copyer.Close() +} + +// Push an artifact to an image registry +func (as ArtifactStore) Push(ctx context.Context, src, dest string, opts libimage.CopyOptions) error { + if len(dest) == 0 { + return ErrEmptyArtifactName + } + destRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", dest)) + if err != nil { + return err + } + srcRef, err := layout.NewReference(as.storePath, src) + if err != nil { + return err + } + copyer, err := libimage.NewCopier(&opts, as.SystemContext, nil) + if err != nil { + return err + } + _, err = copyer.Copy(ctx, srcRef, destRef) + if err != nil { + return err + } + return copyer.Close() +} + +// Add takes one or more local files and adds them to the local artifact store. The empty +// string input is for possible custom artifact types. +func (as ArtifactStore) Add(ctx context.Context, dest string, paths []string, _ string) (*digest.Digest, error) { + if len(dest) == 0 { + return nil, ErrEmptyArtifactName + } + + artifactManifestLayers := make([]specV1.Descriptor, 0) + + // Check if artifact already exists + artifacts, err := as.getArtifacts(ctx, nil) + if err != nil { + return nil, err + } + + // Check if artifact exists; in GetByName not getting an + // error means it exists + if _, _, err := artifacts.GetByNameOrDigest(dest); err == nil { + return nil, fmt.Errorf("artifact %s already exists", dest) + } + + ir, err := layout.NewReference(as.storePath, dest) + if err != nil { + return nil, err + } + + imageDest, err := ir.NewImageDestination(ctx, as.SystemContext) + if err != nil { + return nil, err + } + defer imageDest.Close() + + for _, path := range paths { + // get the new artifact into the local store + newBlobDigest, newBlobSize, err := layout.PutBlobFromLocalFile(ctx, imageDest, path) + if err != nil { + return nil, err + } + detectedType, err := determineManifestType(path) + if err != nil { + return nil, err + } + newArtifactAnnotations := map[string]string{} + newArtifactAnnotations[specV1.AnnotationTitle] = filepath.Base(path) + newLayer := specV1.Descriptor{ + MediaType: detectedType, + Digest: newBlobDigest, + Size: newBlobSize, + Annotations: newArtifactAnnotations, + } + artifactManifestLayers = append(artifactManifestLayers, newLayer) + } + + artifactManifest := specV1.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: specV1.MediaTypeImageManifest, + // TODO This should probably be configurable once the CLI is capable + ArtifactType: "", + Config: specV1.DescriptorEmptyJSON, + Layers: artifactManifestLayers, + } + + rawData, err := json.Marshal(artifactManifest) + if err != nil { + return nil, err + } + if err := imageDest.PutManifest(ctx, rawData, nil); err != nil { + return nil, err + } + unparsed := newUnparsedArtifactImage(ir, artifactManifest) + if err := imageDest.Commit(ctx, unparsed); err != nil { + return nil, err + } + + artifactManifestDigest := digest.FromBytes(rawData) + + // the config is an empty JSON stanza i.e. '{}'; if it does not yet exist, it needs + // to be created + if err := createEmptyStanza(filepath.Join(as.storePath, specV1.ImageBlobsDir, artifactManifestDigest.Algorithm().String(), artifactManifest.Config.Digest.Encoded())); err != nil { + logrus.Errorf("failed to check or write empty stanza file: %v", err) + } + return &artifactManifestDigest, nil +} + +// readIndex is currently unused but I want to keep this around until +// the artifact code is more mature. +func (as ArtifactStore) readIndex() (*specV1.Index, error) { //nolint:unused + index := specV1.Index{} + rawData, err := os.ReadFile(as.indexPath()) + if err != nil { + return nil, err + } + err = json.Unmarshal(rawData, &index) + return &index, err +} + +func (as ArtifactStore) createEmptyManifest() error { + index := specV1.Index{ + MediaType: specV1.MediaTypeImageIndex, + Versioned: specs.Versioned{SchemaVersion: 2}, + } + rawData, err := json.Marshal(&index) + if err != nil { + return err + } + + return os.WriteFile(as.indexPath(), rawData, 0o644) +} + +func (as ArtifactStore) indexPath() string { + return filepath.Join(as.storePath, specV1.ImageIndexFile) +} + +// getArtifacts returns an ArtifactList based on the artifact's store. The return error and +// unused opts is meant for future growth like filters, etc so the API does not change. +func (as ArtifactStore) getArtifacts(ctx context.Context, _ *libartTypes.GetArtifactOptions) (libartifact.ArtifactList, error) { + var ( + al libartifact.ArtifactList + ) + + lrs, err := layout.List(as.storePath) + if err != nil { + return nil, err + } + for _, l := range lrs { + imgSrc, err := l.Reference.NewImageSource(ctx, as.SystemContext) + if err != nil { + return nil, err + } + manifests, err := getManifests(ctx, imgSrc, nil) + imgSrc.Close() + if err != nil { + return nil, err + } + artifact := libartifact.Artifact{ + Manifests: manifests, + } + if val, ok := l.ManifestDescriptor.Annotations[specV1.AnnotationRefName]; ok { + artifact.SetName(val) + } + + al = append(al, &artifact) + } + return al, nil +} + +// getManifests takes an imgSrc and starting digest (nil means "top") and collects all the manifests "under" +// it. this func calls itself recursively with a new startingDigest assuming that we are dealing with +// an index list +func getManifests(ctx context.Context, imgSrc types.ImageSource, startingDigest *digest.Digest) ([]manifest.OCI1, error) { + var ( + manifests []manifest.OCI1 + ) + b, manifestType, err := imgSrc.GetManifest(ctx, startingDigest) + if err != nil { + return nil, err + } + + // this assumes that there are only single, and multi-images + if !manifest.MIMETypeIsMultiImage(manifestType) { + // these are the keepers + mani, err := manifest.OCI1FromManifest(b) + if err != nil { + return nil, err + } + manifests = append(manifests, *mani) + return manifests, nil + } + // We are dealing with an oci index list + maniList, err := manifest.OCI1IndexFromManifest(b) + if err != nil { + return nil, err + } + for _, m := range maniList.Manifests { + iterManifests, err := getManifests(ctx, imgSrc, &m.Digest) + if err != nil { + return nil, err + } + manifests = append(manifests, iterManifests...) + } + return manifests, nil +} + +func createEmptyStanza(path string) error { + if err := fileutils.Exists(path); err == nil { + return nil + } + return os.WriteFile(path, specV1.DescriptorEmptyJSON.Data, 0644) +} + +func determineManifestType(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + // DetectContentType looks at the first 512 bytes + b := make([]byte, 512) + // Because DetectContentType will return a default value + // we don't sweat the error + n, err := f.Read(b) + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return http.DetectContentType(b[:n]), nil +} diff --git a/pkg/libartifact/types/config.go b/pkg/libartifact/types/config.go new file mode 100644 index 0000000000..c52d677942 --- /dev/null +++ b/pkg/libartifact/types/config.go @@ -0,0 +1,5 @@ +package types + +// GetArtifactOptions is a struct containing options that for obtaining artifacts. +// It is meant for future growth or changes required without wacking the API +type GetArtifactOptions struct{} diff --git a/test/e2e/artifact_test.go b/test/e2e/artifact_test.go new file mode 100644 index 0000000000..13deb8589a --- /dev/null +++ b/test/e2e/artifact_test.go @@ -0,0 +1,160 @@ +//go:build linux || freebsd + +package integration + +import ( + "encoding/json" + "fmt" + + "github.com/containers/podman/v5/pkg/libartifact" + . "github.com/containers/podman/v5/test/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gexec" +) + +var _ = Describe("Podman artifact", func() { + BeforeEach(func() { + SkipIfRemote("artifacts are not supported on the remote client yet due to being in development still") + }) + + It("podman artifact ls", func() { + artifact1File, err := createArtifactFile(4192) + Expect(err).ToNot(HaveOccurred()) + artifact1Name := "localhost/test/artifact1" + podmanTest.PodmanExitCleanly([]string{"artifact", "add", artifact1Name, artifact1File}...) + + artifact2File, err := createArtifactFile(10240) + Expect(err).ToNot(HaveOccurred()) + artifact2Name := "localhost/test/artifact2" + podmanTest.PodmanExitCleanly([]string{"artifact", "add", artifact2Name, artifact2File}...) + + // Should be three items in the list + listSession := podmanTest.PodmanExitCleanly([]string{"artifact", "ls"}...) + Expect(listSession.OutputToStringArray()).To(HaveLen(3)) + + // --format should work + listFormatSession := podmanTest.PodmanExitCleanly([]string{"artifact", "ls", "--format", "{{.Repository}}"}...) + output := listFormatSession.OutputToStringArray() + + // There should be only 2 "lines" because the header should not be output + Expect(output).To(HaveLen(2)) + + // Make sure the names are what we expect + Expect(output).To(ContainElement(artifact1Name)) + Expect(output).To(ContainElement(artifact2Name)) + }) + + It("podman artifact simple add", func() { + artifact1File, err := createArtifactFile(1024) + Expect(err).ToNot(HaveOccurred()) + + artifact1Name := "localhost/test/artifact1" + podmanTest.PodmanExitCleanly([]string{"artifact", "add", artifact1Name, artifact1File}...) + + inspectSingleSession := podmanTest.PodmanExitCleanly([]string{"artifact", "inspect", artifact1Name}...) + + a := libartifact.Artifact{} + inspectOut := inspectSingleSession.OutputToString() + err = json.Unmarshal([]byte(inspectOut), &a) + Expect(err).ToNot(HaveOccurred()) + Expect(a.Name).To(Equal(artifact1Name)) + + // Adding an artifact with an existing name should fail + addAgain := podmanTest.Podman([]string{"artifact", "add", artifact1Name, artifact1File}) + addAgain.WaitWithDefaultTimeout() + Expect(addAgain).ShouldNot(ExitCleanly()) + Expect(addAgain.ErrorToString()).To(Equal(fmt.Sprintf("Error: artifact %s already exists", artifact1Name))) + }) + + It("podman artifact add multiple", func() { + artifact1File1, err := createArtifactFile(1024) + Expect(err).ToNot(HaveOccurred()) + artifact1File2, err := createArtifactFile(8192) + Expect(err).ToNot(HaveOccurred()) + + artifact1Name := "localhost/test/artifact1" + + podmanTest.PodmanExitCleanly([]string{"artifact", "add", artifact1Name, artifact1File1, artifact1File2}...) + + inspectSingleSession := podmanTest.PodmanExitCleanly([]string{"artifact", "inspect", artifact1Name}...) + + a := libartifact.Artifact{} + inspectOut := inspectSingleSession.OutputToString() + err = json.Unmarshal([]byte(inspectOut), &a) + Expect(err).ToNot(HaveOccurred()) + Expect(a.Name).To(Equal(artifact1Name)) + + var layerCount int + for _, layer := range a.Manifests { + layerCount += len(layer.Layers) + } + Expect(layerCount).To(Equal(2)) + }) + + It("podman artifact push and pull", func() { + artifact1File, err := createArtifactFile(1024) + Expect(err).ToNot(HaveOccurred()) + + lock, port, err := setupRegistry(nil) + if err == nil { + defer lock.Unlock() + } + Expect(err).ToNot(HaveOccurred()) + + artifact1Name := fmt.Sprintf("localhost:%s/test/artifact1", port) + podmanTest.PodmanExitCleanly([]string{"artifact", "add", artifact1Name, artifact1File}...) + + podmanTest.PodmanExitCleanly([]string{"artifact", "push", "-q", "--tls-verify=false", artifact1Name}...) + + podmanTest.PodmanExitCleanly([]string{"artifact", "rm", artifact1Name}...) + + podmanTest.PodmanExitCleanly([]string{"artifact", "pull", "--tls-verify=false", artifact1Name}...) + + inspectSingleSession := podmanTest.PodmanExitCleanly([]string{"artifact", "inspect", artifact1Name}...) + + a := libartifact.Artifact{} + inspectOut := inspectSingleSession.OutputToString() + err = json.Unmarshal([]byte(inspectOut), &a) + Expect(err).ToNot(HaveOccurred()) + Expect(a.Name).To(Equal(artifact1Name)) + }) + + It("podman artifact remove", func() { + // Trying to remove an image that does not exist should fail + rmFail := podmanTest.Podman([]string{"artifact", "rm", "foobar"}) + rmFail.WaitWithDefaultTimeout() + Expect(rmFail).Should(Exit(125)) + Expect(rmFail.ErrorToString()).Should(Equal(fmt.Sprintf("Error: no artifact found with name or digest of %s", "foobar"))) + + // Add an artifact to remove later + artifact1File, err := createArtifactFile(4192) + Expect(err).ToNot(HaveOccurred()) + artifact1Name := "localhost/test/artifact1" + addArtifact1 := podmanTest.PodmanExitCleanly([]string{"artifact", "add", artifact1Name, artifact1File}...) + + // Removing that artifact should work + rmWorks := podmanTest.PodmanExitCleanly([]string{"artifact", "rm", artifact1Name}...) + // The digests printed by removal should be the same as the digest that was added + Expect(addArtifact1.OutputToString()).To(Equal(rmWorks.OutputToString())) + + // Inspecting that the removed artifact should fail + inspectArtifact := podmanTest.Podman([]string{"artifact", "inspect", artifact1Name}) + inspectArtifact.WaitWithDefaultTimeout() + Expect(inspectArtifact).Should(Exit(125)) + Expect(inspectArtifact.ErrorToString()).To(Equal(fmt.Sprintf("Error: no artifact found with name or digest of %s", artifact1Name))) + }) + + It("podman artifact inspect with full or partial digest", func() { + artifact1File, err := createArtifactFile(4192) + Expect(err).ToNot(HaveOccurred()) + artifact1Name := "localhost/test/artifact1" + addArtifact1 := podmanTest.PodmanExitCleanly([]string{"artifact", "add", artifact1Name, artifact1File}...) + + artifactDigest := addArtifact1.OutputToString() + + podmanTest.PodmanExitCleanly([]string{"artifact", "inspect", artifactDigest}...) + podmanTest.PodmanExitCleanly([]string{"artifact", "inspect", artifactDigest[:12]}...) + + }) +}) diff --git a/test/e2e/common_test.go b/test/e2e/common_test.go index 65edfc050d..65a04de847 100644 --- a/test/e2e/common_test.go +++ b/test/e2e/common_test.go @@ -27,6 +27,7 @@ import ( "github.com/containers/podman/v5/libpod/define" "github.com/containers/podman/v5/pkg/inspect" . "github.com/containers/podman/v5/test/utils" + "github.com/containers/podman/v5/utils" "github.com/containers/storage/pkg/lockfile" "github.com/containers/storage/pkg/reexec" "github.com/containers/storage/pkg/stringid" @@ -1537,3 +1538,49 @@ func CopySymLink(source, dest string) error { func UsingCacheRegistry() bool { return os.Getenv("CI_USE_REGISTRY_CACHE") != "" } + +func setupRegistry(portOverride *int) (*lockfile.LockFile, string, error) { + var port string + if isRootless() { + if err := podmanTest.RestoreArtifact(REGISTRY_IMAGE); err != nil { + return nil, "", err + } + } + + if portOverride != nil { + port = strconv.Itoa(*portOverride) + } else { + p, err := utils.GetRandomPort() + if err != nil { + return nil, "", err + } + port = strconv.Itoa(p) + } + + lock := GetPortLock(port) + + session := podmanTest.Podman([]string{"run", "-d", "--name", "registry", "-p", fmt.Sprintf("%s:5000", port), REGISTRY_IMAGE, "/entrypoint.sh", "/etc/docker/registry/config.yml"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + if !WaitContainerReady(podmanTest, "registry", "listening on", 20, 1) { + lock.Unlock() + Skip("Cannot start docker registry.") + } + return lock, port, nil +} + +func createArtifactFile(numBytes int64) (string, error) { + artifactDir := filepath.Join(podmanTest.TempDir, "artifacts") + if err := os.MkdirAll(artifactDir, 0755); err != nil { + return "", err + } + filename := RandomString(8) + outFile := filepath.Join(artifactDir, filename) + session := podmanTest.Podman([]string{"run", "-v", fmt.Sprintf("%s:/artifacts:z", artifactDir), ALPINE, "dd", "if=/dev/urandom", fmt.Sprintf("of=%s", filepath.Join("/artifacts", filename)), "bs=1b", fmt.Sprintf("count=%d", numBytes)}) + session.WaitWithDefaultTimeout() + if session.ExitCode() != 0 { + return "", errors.New("unable to generate artifact file") + } + return outFile, nil +}