Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ func buildCmd() *cobra.Command {
var includePaths []string
var ignoreSignatures bool
var sizeLimits options.SizeLimits
var extraPythonPackages []string
var extraPythonIndexes []string

cmd := &cobra.Command{
Use: "build",
Expand Down Expand Up @@ -119,6 +121,8 @@ Along the image, apko will generate SBOMs (software bill of materials) describin
build.WithIncludePaths(includePaths),
build.WithIgnoreSignatures(ignoreSignatures),
build.WithSizeLimits(sizeLimits),
build.WithExtraEcosystemPackages("python", extraPythonPackages),
build.WithExtraEcosystemIndexes("python", extraPythonIndexes),
)
},
}
Expand All @@ -139,6 +143,8 @@ Along the image, apko will generate SBOMs (software bill of materials) describin
cmd.Flags().StringVar(&lockfile, "lockfile", "", "a path to .lock.json file (e.g. produced by apko lock) that constraints versions of packages to the listed ones (default '' means no additional constraints)")
cmd.Flags().StringSliceVar(&includePaths, "include-paths", []string{}, "Additional include paths where to look for input files (config, base image, etc.). By default apko will search for paths only in workdir. Include paths may be absolute, or relative. Relative paths are interpreted relative to workdir. For adding extra paths for packages, use --repository-append.")
cmd.Flags().BoolVar(&ignoreSignatures, "ignore-signatures", false, "ignore repository signature verification")
cmd.Flags().StringSliceVar(&extraPythonPackages, "ecosystem-python-package-append", []string{}, "extra Python packages to include (e.g., flask==3.0.0)")
cmd.Flags().StringSliceVar(&extraPythonIndexes, "ecosystem-python-index-append", []string{}, "extra Python package index URLs to use")
addClientLimitFlags(cmd, &sizeLimits)
return cmd
}
Expand Down
26 changes: 26 additions & 0 deletions internal/cli/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import (
apkfs "chainguard.dev/apko/pkg/apk/fs"
"chainguard.dev/apko/pkg/build"
"chainguard.dev/apko/pkg/build/types"
"chainguard.dev/apko/pkg/ecosystem"
_ "chainguard.dev/apko/pkg/ecosystem/python"
pkglock "chainguard.dev/apko/pkg/lock"
)

Expand Down Expand Up @@ -245,6 +247,30 @@ func LockCmd(ctx context.Context, output string, archs []types.Architecture, opt
}
}

// Resolve ecosystem packages
for name, ecoConfig := range ic.Contents.Ecosystems {
installer, ok := ecosystem.Get(name)
if !ok {
return fmt.Errorf("unknown ecosystem: %s", name)
}
for _, arch := range archs {
resolved, err := installer.Resolve(ctx, ecoConfig, arch)
if err != nil {
return fmt.Errorf("resolving %s packages for %s: %w", name, arch, err)
}
for _, pkg := range resolved {
lock.Contents.EcosystemPackages = append(lock.Contents.EcosystemPackages, pkglock.LockEcosystemPkg{
Ecosystem: pkg.Ecosystem,
Name: pkg.Name,
Version: pkg.Version,
URL: pkg.URL,
Checksum: pkg.Checksum,
Architecture: arch.ToAPK(),
})
}
}
}

// Sort keyrings by name for reproducible lock files
sort.Slice(lock.Contents.Keyrings, func(i, j int) bool {
return lock.Contents.Keyrings[i].Name < lock.Contents.Keyrings[j].Name
Expand Down
19 changes: 19 additions & 0 deletions pkg/build/build_implementation.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
ldsocache "chainguard.dev/apko/internal/ldso-cache"
"chainguard.dev/apko/pkg/apk/apk"
apkfs "chainguard.dev/apko/pkg/apk/fs"
"chainguard.dev/apko/pkg/ecosystem"
_ "chainguard.dev/apko/pkg/ecosystem/python" // Register python ecosystem installer.
"chainguard.dev/apko/pkg/lock"
"chainguard.dev/apko/pkg/options"
)
Expand Down Expand Up @@ -177,6 +179,23 @@
}
}

// Install ecosystem packages (python, etc.) after APK packages so that
// the language runtime is available for version detection.
if len(bc.ic.Contents.Ecosystems) > 0 {
env, err := ecosystem.InstallAll(ctx, bc.fs, bc.ic.Contents.Ecosystems, bc.o.Arch)
if err != nil {
return nil, fmt.Errorf("installing ecosystem packages: %w", err)
}
if len(env) > 0 {
if bc.ic.Environment == nil {
bc.ic.Environment = make(map[string]string)
}
for k, v := range env {
bc.ic.Environment[k] = v

Check failure on line 194 in pkg/build/build_implementation.go

View workflow job for this annotation

GitHub Actions / lint

mapsloop: Replace m[k]=v loop with maps.Copy (modernize)
}
}
}

// For now adding additional accounts is banned when using base image. On the other hand, we don't want to
// wipe out the users set in base.
// If one wants to add a support for adding additional users they would need to look into this piece of code.
Expand Down
32 changes: 32 additions & 0 deletions pkg/build/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,35 @@ func WithSizeLimits(limits options.SizeLimits) Option {
return nil
}
}

// WithExtraEcosystemPackages adds extra ecosystem packages to the build.
func WithExtraEcosystemPackages(ecosystem string, packages []string) Option {
return func(bc *Context) error {
if len(packages) == 0 {
return nil
}
if bc.ic.Contents.Ecosystems == nil {
bc.ic.Contents.Ecosystems = make(map[string]types.EcosystemConfig)
}
eco := bc.ic.Contents.Ecosystems[ecosystem]
eco.Packages = append(eco.Packages, packages...)
bc.ic.Contents.Ecosystems[ecosystem] = eco
return nil
}
}

// WithExtraEcosystemIndexes adds extra ecosystem indexes to the build.
func WithExtraEcosystemIndexes(ecosystem string, indexes []string) Option {
return func(bc *Context) error {
if len(indexes) == 0 {
return nil
}
if bc.ic.Contents.Ecosystems == nil {
bc.ic.Contents.Ecosystems = make(map[string]types.EcosystemConfig)
}
eco := bc.ic.Contents.Ecosystems[ecosystem]
eco.Indexes = append(eco.Indexes, indexes...)
bc.ic.Contents.Ecosystems[ecosystem] = eco
return nil
}
}
29 changes: 29 additions & 0 deletions pkg/build/types/image_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,27 @@ func (i *ImageContents) MergeInto(target *ImageContents) error {
if target.BaseImage == nil {
target.BaseImage = i.BaseImage
}
// Merge ecosystem configs
if len(i.Ecosystems) > 0 {
if target.Ecosystems == nil {
target.Ecosystems = make(map[string]EcosystemConfig)
}
for name, eco := range i.Ecosystems {
if existing, ok := target.Ecosystems[name]; ok {
existing.Indexes = slices.Concat(eco.Indexes, existing.Indexes)
existing.Packages = slices.Concat(eco.Packages, existing.Packages)
if existing.PythonVersion == "" {
existing.PythonVersion = eco.PythonVersion
}
if existing.Venv == "" {
existing.Venv = eco.Venv
}
target.Ecosystems[name] = existing
} else {
target.Ecosystems[name] = eco
}
}
}
return nil
}

Expand Down Expand Up @@ -295,6 +316,14 @@ func (ic *ImageConfiguration) Summarize(ctx context.Context) {
log.Infof(" - gid=%d(%s) members=%v", g.GID, g.GroupName, g.Members)
}
}
if len(ic.Contents.Ecosystems) > 0 {
log.Infof(" ecosystems:")
for name, eco := range ic.Contents.Ecosystems {
log.Infof(" %s:", name)
log.Infof(" indexes: %v", eco.Indexes)
log.Infof(" packages: %v", eco.Packages)
}
}
if len(ic.Annotations) > 0 {
log.Infof(" annotations:")
for k, v := range ic.Annotations {
Expand Down
23 changes: 23 additions & 0 deletions pkg/build/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ type BaseImageDescriptor struct {
APKIndex string `json:"apkindex,omitempty" yaml:"apkindex,omitempty"`
}

// EcosystemConfig holds configuration for a non-APK package ecosystem (e.g., python).
type EcosystemConfig struct {
// Indexes is a list of package index URLs (e.g., PyPI simple API URLs).
Indexes []string `json:"indexes,omitempty" yaml:"indexes,omitempty"`
// Packages is a list of package specifications (e.g., "flask==3.0.0").
Packages []string `json:"packages,omitempty" yaml:"packages,omitempty"`
// PythonVersion overrides auto-detection of the Python version (e.g., "3.12").
PythonVersion string `json:"python_version,omitempty" yaml:"python_version,omitempty"`
// Venv is an optional path for a virtual environment (e.g., "/app/venv").
// When set, packages are installed into the venv instead of the system site-packages,
// and VIRTUAL_ENV / PATH are set automatically.
Venv string `json:"venv,omitempty" yaml:"venv,omitempty"`
}

type ImageContents struct {
// A list of apk repositories to use for pulling packages at build time,
// which are not installed into /etc/apk/repositories in the image (to
Expand All @@ -122,6 +136,8 @@ type ImageContents struct {
Packages []string `json:"packages,omitempty" yaml:"packages,omitempty"`
// Optional: Base image to build on top of. Warning: Experimental.
BaseImage *BaseImageDescriptor `json:"baseimage,omitempty" yaml:"baseimage,omitempty" apko:"experimental"`
// Optional: Non-APK ecosystem packages to install (e.g., pip packages).
Ecosystems map[string]EcosystemConfig `json:"ecosystems,omitempty" yaml:"ecosystems,omitempty"`
}

// MarshalYAML implements yaml.Marshaler for ImageContents, redacting URLs in
Expand All @@ -138,6 +154,13 @@ func (i ImageContents) MarshalYAML() (any, error) {
return nil, err
}

for name, eco := range ri.Ecosystems {
if err := processRepositoryURLs(eco.Indexes); err != nil {
return nil, err
}
ri.Ecosystems[name] = eco
}

for idx, key := range ri.Keyring {
rawURL := key
parsed, err := url.Parse(rawURL)
Expand Down
95 changes: 95 additions & 0 deletions pkg/ecosystem/ecosystem.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright 2024 Chainguard, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ecosystem

import (
"context"
"fmt"
"sync"

apkfs "chainguard.dev/apko/pkg/apk/fs"
"chainguard.dev/apko/pkg/build/types"
)

// ResolvedPackage represents a package that has been resolved to a specific
// version and download URL.
type ResolvedPackage struct {
Ecosystem string
Name string
Version string
URL string
Checksum string // "sha256:<hex>"
}

// Installer is the interface that ecosystem package installers must implement.
type Installer interface {
// Name returns the ecosystem name (e.g., "python").
Name() string
// Resolve resolves the requested packages to specific versions and URLs.
Resolve(ctx context.Context, config types.EcosystemConfig, arch types.Architecture) ([]ResolvedPackage, error)
// Install extracts resolved packages into the filesystem.
// Returns environment variables that should be set in the image configuration.
Install(ctx context.Context, fs apkfs.FullFS, packages []ResolvedPackage, config types.EcosystemConfig) (map[string]string, error)
}

var (
registryMu sync.RWMutex
registry = map[string]func() Installer{}
)

// Register registers an ecosystem installer factory.
func Register(name string, factory func() Installer) {
registryMu.Lock()
defer registryMu.Unlock()
registry[name] = factory
}

// Get returns an installer for the named ecosystem.
func Get(name string) (Installer, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
factory, ok := registry[name]
if !ok {
return nil, false
}
return factory(), true
}

// InstallAll installs packages for all configured ecosystems.
// Returns environment variables that should be set in the image configuration.
func InstallAll(ctx context.Context, fs apkfs.FullFS, ecosystems map[string]types.EcosystemConfig, arch types.Architecture) (map[string]string, error) {
env := map[string]string{}
for name, config := range ecosystems {
installer, ok := Get(name)
if !ok {
return nil, fmt.Errorf("unknown ecosystem: %s", name)
}
resolved, err := installer.Resolve(ctx, config, arch)
if err != nil {
return nil, fmt.Errorf("resolving %s packages: %w", name, err)
}
if len(resolved) == 0 {
continue
}
vars, err := installer.Install(ctx, fs, resolved, config)
if err != nil {
return nil, fmt.Errorf("installing %s packages: %w", name, err)
}
for k, v := range vars {
env[k] = v

Check failure on line 91 in pkg/ecosystem/ecosystem.go

View workflow job for this annotation

GitHub Actions / lint

mapsloop: Replace m[k]=v loop with maps.Copy (modernize)
}
}
return env, nil
}
Loading
Loading