package ociimg import ( "archive/tar" "context" "fmt" "io" "log/slog" "os" "sync" "time" "github.com/docker/docker/client" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/tarball" "github.com/google/go-containerregistry/pkg/v1/types" specsv1 "github.com/opencontainers/image-spec/specs-go/v1" "code.icb4dc0.de/buildr/buildr/modules" ) var ( _ modules.Module = (*ContainerImage)(nil) defaultCreationTime = time.Unix(0, 0) ) type ContainerImage struct { Content map[string]string `hcl:"content"` cache *sync.Map BaseImage string `hcl:"base_image"` ImageName string `hcl:"image_name"` Platform string `hcl:"platform"` Tags []string `hcl:"tags"` Entrypoint []string `hcl:"entrypoint,optional"` Command []string `hcl:"command,optional"` RegistryCredentials []RegistryAuth `hcl:"registry_auth,block"` publishers []Publisher PublishToDaemon bool `hcl:"publish_to_daemon,optional"` PublishToRegistry bool `hcl:"publish_to_registry,optional"` } func (o ContainerImage) Type() string { return "container_image" } func (o ContainerImage) Category() modules.Category { return modules.CategoryPackage } //nolint:funlen,gocyclo // refactor later func (o ContainerImage) Execute(ctx modules.ExecutionContext) error { o.cache = new(sync.Map) logger := ctx.Logger() if err := o.initPublishers(logger); err != nil { return err } baseRef, base, err := o.getBaseImage(ctx, logger, o.BaseImage) if err != nil { return err } mt, err := base.MediaType() if err != nil { return err } // Annotate the base image we pass to the build function with // annotations indicating the digest (and possibly tag) of the // base image. This will be inherited by the image produced. if mt != types.DockerManifestList { var baseDigest v1.Hash if baseDigest, err = base.Digest(); err != nil { return err } anns := map[string]string{ specsv1.AnnotationBaseImageDigest: baseDigest.String(), specsv1.AnnotationBaseImageName: baseRef.Name(), } base = mutate.Annotations(base, anns).(imageOrIndex) } platform, err := v1.ParsePlatform(o.Platform) if err != nil { return err } var buildResult imageOrIndex //nolint:exhaustive // not necessary switch mt { case types.OCIImageIndex, types.DockerManifestList: baseIdx, ok := base.(v1.ImageIndex) if !ok { return fmt.Errorf("failed to interpret base as index: %v", base) } var im *v1.IndexManifest if im, err = baseIdx.IndexManifest(); err != nil { return err } var ( img v1.Image desc v1.Descriptor ) img, desc, err = imageFromIndex(baseIdx, platform) if err != nil { return err } img = mutate.Annotations(img, map[string]string{ specsv1.AnnotationBaseImageDigest: desc.Digest.String(), specsv1.AnnotationBaseImageName: baseRef.Name(), }).(v1.Image) img, err = o.buildImage(ctx, img, platform) if err != nil { return err } adds := mutate.IndexAddendum{ Add: img, Descriptor: v1.Descriptor{ URLs: desc.URLs, MediaType: desc.MediaType, Annotations: desc.Annotations, Platform: desc.Platform, }, } buildResult = mutate.AppendManifests( mutate.Annotations( mutate.IndexMediaType(empty.Index, mt), im.Annotations, ).(v1.ImageIndex), adds, ) case types.OCIManifestSchema1, types.DockerManifestSchema2: baseImage, ok := base.(v1.Image) if !ok { return fmt.Errorf("failed to interpret base as image: %v", base) } buildResult, err = o.buildImage(ctx, baseImage, platform) if err != nil { return err } default: return nil } for i := range o.publishers { if _, err := o.publishers[i].Publish(ctx, buildResult); err != nil { return err } } return nil } func (o *ContainerImage) initPublishers(logger *slog.Logger) error { if o.PublishToDaemon { cli, err := client.NewClientWithOpts( client.WithHostFromEnv(), client.WithTLSClientConfigFromEnv(), client.WithVersionFromEnv(), ) if err != nil { return err } o.publishers = append(o.publishers, newContainerDaemon(o.ImageName, logger, o.Tags, cli)) } if o.PublishToRegistry { opts := []registryPublisherOption{ withTags(o.Tags...), } if o.RegistryCredentials != nil { opts = append(opts, withKeyChain(authn.NewMultiKeychain(authn.DefaultKeychain, KeyChain(o.RegistryCredentials)))) } o.publishers = append(o.publishers, newRegistryPublisher(o.ImageName, logger, opts...)) } return nil } func (o ContainerImage) buildImage(ctx modules.ExecutionContext, base v1.Image, platform *v1.Platform) (v1.Image, error) { // Layers should be typed to match the underlying image, since some // registries reject mixed-type layers. var layerMediaType types.MediaType mt, err := base.MediaType() if err != nil { return nil, err } //nolint:exhaustive // not necessary switch mt { case types.OCIManifestSchema1: layerMediaType = types.OCILayer case types.DockerManifestSchema2: layerMediaType = types.DockerLayer } if err := o.platformMatches(platform); err != nil { return nil, err } contentTemp, err := os.CreateTemp(os.TempDir(), "buildr-oci-image-") if err != nil { return nil, err } defer func() { tempFilePath := contentTemp.Name() _ = contentTemp.Close() _ = os.Remove(tempFilePath) }() if err := o.tarFiles(contentTemp, ctx.WorkingDir()); err != nil { return nil, err } layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { if _, err = contentTemp.Seek(0, 0); err != nil { return nil, err } return io.NopCloser(contentTemp), nil }, tarball.WithCompressedCaching, tarball.WithMediaType(layerMediaType)) if err != nil { return nil, err } withContent, err := mutate.Append(base, mutate.Addendum{ Layer: layer, History: v1.History{ Author: "buildr", CreatedBy: "oci_image", Created: v1.Time{Time: defaultCreationTime}, }, }) if err != nil { return nil, err } cfg, err := withContent.ConfigFile() if err != nil { return nil, err } cfg = cfg.DeepCopy() cfg.Config.Entrypoint = o.Entrypoint cfg.Config.Cmd = o.Command cfg.Author = "buildr" if cfg.Config.Labels == nil { cfg.Config.Labels = map[string]string{} } return mutate.ConfigFile(withContent, cfg) } func (o ContainerImage) tarFiles(outWriter io.Writer, cwd string) error { tw := tar.NewWriter(outWriter) defer func() { _ = tw.Close() }() tree, err := buildDirFromContent(cwd, o.Content) if err != nil { return err } return tree.writeToTar(tw, "/") } func (o ContainerImage) platformMatches(platform *v1.Platform) error { parsed, err := v1.ParsePlatform(o.Platform) if err != nil { return err } if parsed.Satisfies(*platform) { return nil } return fmt.Errorf("platform %s does not satisfy %s", platform.String(), o.Platform) } func (o ContainerImage) getBaseImage(ctx context.Context, logger *slog.Logger, imageRef string) (name.Reference, imageOrIndex, error) { ref, err := name.ParseReference(imageRef, name.WithDefaultRegistry("docker.io")) if err != nil { return nil, nil, fmt.Errorf("parsing base image (%q): %w", imageRef, err) } if v, ok := o.cache.Load(ref.String()); ok { return ref, v.(imageOrIndex), nil } result, err := fetch(ctx, ref) if err != nil { return ref, result, err } if dig, ok := ref.(name.Digest); ok { logger.Info("Using base", slog.String("base_image", fmt.Sprintf("%s@%s", ref, dig))) } else { dig, err := result.Digest() if err != nil { return ref, result, err } logger.Info("Using base", slog.String("base_image", fmt.Sprintf("%s@%s", ref, dig))) } o.cache.Store(ref.String(), result) return ref, result, nil }