Skip to content

Commit

Permalink
Build working Windows container images (ko-build#374)
Browse files Browse the repository at this point in the history
* Build working Windows container images

Add e2e tests that run on Windows and cover kodata behavior

* now successfully skipping symlinks on windows :-/

* fix e2e test on windows, that relied on a symlink in kodata after all

* document windows symlink issue

* review feedback

* re-add kodata symlink tests for linux
  • Loading branch information
imjasonh authored Jul 27, 2021
1 parent e947aa3 commit 6905332
Show file tree
Hide file tree
Showing 10 changed files with 188 additions and 70 deletions.
51 changes: 51 additions & 0 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Basic e2e test

on:
pull_request:
branches: ['main']

jobs:
e2e:
strategy:
fail-fast: false
matrix:
platform:
- ubuntu-latest
- windows-latest
name: e2e ${{ matrix.platform }}
runs-on: ${{ matrix.platform }}

steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: 1.16.x

- name: Build and run ko container
env:
KO_DOCKER_REPO: ko.local
shell: bash
run: |
set -euxo pipefail
# eval `go env`, compatible with Windows and Linux
# cribbed from https://gist.github.com/Syeberman/39d81b1e17d091be5657ecd6fbff0753
eval $(go env | sed -r 's/^(set )?(\w+)=("?)(.*)\3$/\2="\4"/gm')
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
export KO_DEFAULTBASEIMAGE=mcr.microsoft.com/windows/nanoserver:1809
fi
echo platform is ${GOOS}/${GOARCH}
# Build and run the ko binary, which should be runnable.
docker run $(go run ./ publish ./ --platform=${GOOS}/${GOARCH} --preserve-import-paths) version
# Build and run the test/ binary, which should log "Hello there" served from KO_DATA_PATH
testimg=$(go run ./ publish ./test --platform=${GOOS}/${GOARCH} --preserve-import-paths)
docker run ${testimg} --wait=false 2>&1 | grep "Hello there"
# Check that symlinks in kodata are chased.
# Skip this test on Windows.
if [[ "$RUNNER_OS" == "Linux" ]]; then
docker run ${testimg} --wait=false -f HEAD
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Ignore GoLand (IntelliJ) files.
.idea/

ko
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,24 @@ timestamp with:
export KO_DATA_DATE_EPOCH=$(git log -1 --format='%ct')
```

## Can I optimize images for [eStargz support](https://github.com/containerd/stargz-snapshotter/blob/v0.2.0/docs/stargz-estargz.md)?
## Can I build Windows containers?

Yes, but support for Windows containers is new, experimental, and tenuous. Be prepared to file bugs. 🐛

The default base image does not provide a Windows image.
You can try out building a Windows container image by [setting the base image](#overriding-base-images) to a Windows base image and building with `--platform=windows/amd64` or `--platform=all`:

For example, to build a Windows container image for `ko`, from within this repo:

```
KO_DEFAULTBASEIMAGE=mcr.microsoft.com/windows/nanoserver:1809 ko publish ./ --platform=windows/amd64
```

### Known issues 🐛

- Symlinks in `kodata` are ignored when building Windows images; only regular files and directories will be included in the Windows image.

## Can I optimize images for [eStargz support](https://github.com/containerd/stargz-snapshotter/blob/v0.7.0/docs/stargz-estargz.md)?

Yes! Set the environment variable `GGCR_EXPERIMENT_ESTARGZ=1` to produce
eStargz-optimized images.
Expand Down
Binary file added ko
Binary file not shown.
169 changes: 112 additions & 57 deletions pkg/build/gobuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import (
)

const (
appDir = "/ko-app"
defaultAppFilename = "ko-app"

gorootWarningTemplate = `NOTICE!
Expand Down Expand Up @@ -173,7 +172,6 @@ func moduleInfo(ctx context.Context, dir string) (*modules, error) {

for {
var info modInfo

err := dec.Decode(&info)
if err == io.EOF {
// all done
Expand Down Expand Up @@ -428,40 +426,42 @@ func appFilename(importpath string) string {
return base
}

func tarAddDirectories(tw *tar.Writer, dir string, creationTime v1.Time) error {
if dir == "." || dir == string(filepath.Separator) {
return nil
}

// Write parent directories first
if err := tarAddDirectories(tw, filepath.Dir(dir), creationTime); err != nil {
return err
}

// write the directory header to the tarball archive
if err := tw.WriteHeader(&tar.Header{
Name: dir,
Typeflag: tar.TypeDir,
// Use a fixed Mode, so that this isn't sensitive to the directory and umask
// under which it was created. Additionally, windows can only set 0222,
// 0444, or 0666, none of which are executable.
Mode: 0555,
ModTime: creationTime.Time,
}); err != nil {
return err
}

return nil
}
// userOwnerAndGroupSID is a magic value needed to make the binary executable
// in a Windows container.
//
// owner: BUILTIN/Users group: BUILTIN/Users ($sddlValue="O:BUG:BU")
const userOwnerAndGroupSID = "AQAAgBQAAAAkAAAAAAAAAAAAAAABAgAAAAAABSAAAAAhAgAAAQIAAAAAAAUgAAAAIQIAAA=="

func tarBinary(name, binary string, creationTime v1.Time) (*bytes.Buffer, error) {
func tarBinary(name, binary string, creationTime v1.Time, platform *v1.Platform) (*bytes.Buffer, error) {
buf := bytes.NewBuffer(nil)
tw := tar.NewWriter(buf)
defer tw.Close()

// write the parent directories to the tarball archive
if err := tarAddDirectories(tw, path.Dir(name), creationTime); err != nil {
return nil, err
// Write the parent directories to the tarball archive.
// For Windows, the layer must contain a Hives/ directory, and the root
// of the actual filesystem goes in a Files/ directory.
// For Linux, the binary goes into /ko-app/
dirs := []string{"ko-app"}
if platform.OS == "windows" {
dirs = []string{
"Hives",
"Files",
"Files/ko-app",
}
name = "Files" + name + ".exe"
}
for _, dir := range dirs {
if err := tw.WriteHeader(&tar.Header{
Name: dir,
Typeflag: tar.TypeDir,
// Use a fixed Mode, so that this isn't sensitive to the directory and umask
// under which it was created. Additionally, windows can only set 0222,
// 0444, or 0666, none of which are executable.
Mode: 0555,
ModTime: creationTime.Time,
}); err != nil {
return nil, fmt.Errorf("writing dir %q: %v", dir, err)
}
}

file, err := os.Open(binary)
Expand All @@ -483,6 +483,13 @@ func tarBinary(name, binary string, creationTime v1.Time) (*bytes.Buffer, error)
Mode: 0555,
ModTime: creationTime.Time,
}
if platform.OS == "windows" {
// This magic value is for some reason needed for Windows to be
// able to execute the binary.
header.PAXRecords = map[string]string{
"MSWINDOWS.rawsd": userOwnerAndGroupSID,
}
}
// write the header to the tarball archive
if err := tw.WriteHeader(header); err != nil {
return nil, err
Expand All @@ -509,19 +516,10 @@ const kodataRoot = "/var/run/ko"
// walkRecursive performs a filepath.Walk of the given root directory adding it
// to the provided tar.Writer with root -> chroot. All symlinks are dereferenced,
// which is what leads to recursion when we encounter a directory symlink.
func walkRecursive(tw *tar.Writer, root, chroot string, creationTime v1.Time) error {
func walkRecursive(tw *tar.Writer, root, chroot string, creationTime v1.Time, platform *v1.Platform) error {
return filepath.Walk(root, func(hostPath string, info os.FileInfo, err error) error {
if hostPath == root {
// Add an entry for the root directory of our walk.
return tw.WriteHeader(&tar.Header{
Name: chroot,
Typeflag: tar.TypeDir,
// Use a fixed Mode, so that this isn't sensitive to the directory and umask
// under which it was created. Additionally, windows can only set 0222,
// 0444, or 0666, none of which are executable.
Mode: 0555,
ModTime: creationTime.Time,
})
return nil
}
if err != nil {
return fmt.Errorf("filepath.Walk(%q): %w", root, err)
Expand All @@ -532,6 +530,14 @@ func walkRecursive(tw *tar.Writer, root, chroot string, creationTime v1.Time) er
}
newPath := path.Join(chroot, filepath.ToSlash(hostPath[len(root):]))

// Don't chase symlinks on Windows, where cross-compiled symlink support is not possible.
if platform.OS == "windows" {
if info.Mode()&os.ModeSymlink != 0 {
log.Println("skipping symlink in kodata for windows:", info.Name())
return nil
}
}

evalPath, err := filepath.EvalSymlinks(hostPath)
if err != nil {
return fmt.Errorf("filepath.EvalSymlinks(%q): %w", hostPath, err)
Expand All @@ -544,7 +550,7 @@ func walkRecursive(tw *tar.Writer, root, chroot string, creationTime v1.Time) er
}
// Skip other directories.
if info.Mode().IsDir() {
return walkRecursive(tw, evalPath, newPath, creationTime)
return walkRecursive(tw, evalPath, newPath, creationTime, platform)
}

// Open the file to copy it into the tarball.
Expand All @@ -555,7 +561,7 @@ func walkRecursive(tw *tar.Writer, root, chroot string, creationTime v1.Time) er
defer file.Close()

// Copy the file into the image tarball.
if err := tw.WriteHeader(&tar.Header{
header := &tar.Header{
Name: newPath,
Size: info.Size(),
Typeflag: tar.TypeReg,
Expand All @@ -564,7 +570,15 @@ func walkRecursive(tw *tar.Writer, root, chroot string, creationTime v1.Time) er
// 0444, or 0666, none of which are executable.
Mode: 0555,
ModTime: creationTime.Time,
}); err != nil {
}
if platform.OS == "windows" {
// This magic value is for some reason needed for Windows to be
// able to execute the binary.
header.PAXRecords = map[string]string{
"MSWINDOWS.rawsd": userOwnerAndGroupSID,
}
}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("tar.Writer.WriteHeader(%q): %w", newPath, err)
}
if _, err := io.Copy(tw, file); err != nil {
Expand All @@ -574,7 +588,7 @@ func walkRecursive(tw *tar.Writer, root, chroot string, creationTime v1.Time) er
})
}

func (g *gobuild) tarKoData(ref reference) (*bytes.Buffer, error) {
func (g *gobuild) tarKoData(ref reference, platform *v1.Platform) (*bytes.Buffer, error) {
buf := bytes.NewBuffer(nil)
tw := tar.NewWriter(buf)
defer tw.Close()
Expand All @@ -586,7 +600,41 @@ func (g *gobuild) tarKoData(ref reference) (*bytes.Buffer, error) {

creationTime := g.kodataCreationTime

return buf, walkRecursive(tw, root, kodataRoot, creationTime)
// Write the parent directories to the tarball archive.
// For Windows, the layer must contain a Hives/ directory, and the root
// of the actual filesystem goes in a Files/ directory.
// For Linux, kodata starts at /var/run/ko.
chroot := kodataRoot
dirs := []string{
"/var",
"/var/run",
"/var/run/ko",
}
if platform.OS == "windows" {
chroot = "Files" + kodataRoot
dirs = []string{
"Hives",
"Files",
"Files/var",
"Files/var/run",
"Files/var/run/ko",
}
}
for _, dir := range dirs {
if err := tw.WriteHeader(&tar.Header{
Name: dir,
Typeflag: tar.TypeDir,
// Use a fixed Mode, so that this isn't sensitive to the directory and umask
// under which it was created. Additionally, windows can only set 0222,
// 0444, or 0666, none of which are executable.
Mode: 0555,
ModTime: creationTime.Time,
}); err != nil {
return nil, fmt.Errorf("writing dir %q: %v", dir, err)
}
}

return buf, walkRecursive(tw, root, chroot, creationTime, platform)
}

func createTemplateData() map[string]interface{} {
Expand Down Expand Up @@ -681,8 +729,9 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, baseRef name.Refe
defer os.RemoveAll(filepath.Dir(file))

var layers []mutate.Addendum

// Create a layer from the kodata directory under this import path.
dataLayerBuf, err := g.tarKoData(ref)
dataLayerBuf, err := g.tarKoData(ref, platform)
if err != nil {
return nil, err
}
Expand All @@ -702,10 +751,10 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, baseRef name.Refe
},
})

appPath := path.Join(appDir, appFilename(ref.Path()))
appPath := path.Join("/ko-app", appFilename(ref.Path()))

// Construct a tarball with the binary and produce a layer.
binaryLayerBuf, err := tarBinary(appPath, file, v1.Time{})
binaryLayerBuf, err := tarBinary(appPath, file, v1.Time{}, platform)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -748,8 +797,14 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, baseRef name.Refe

cfg = cfg.DeepCopy()
cfg.Config.Entrypoint = []string{appPath}
updatePath(cfg)
cfg.Config.Env = append(cfg.Config.Env, "KO_DATA_PATH="+kodataRoot)
if platform.OS == "windows" {
cfg.Config.Entrypoint = []string{`C:\ko-app\` + appFilename(ref.Path()) + ".exe"}
updatePath(cfg, `C:\ko-app`)
cfg.Config.Env = append(cfg.Config.Env, `KO_DATA_PATH=C:\var\run\ko`)
} else {
updatePath(cfg, appPath)
cfg.Config.Env = append(cfg.Config.Env, "KO_DATA_PATH="+kodataRoot)
}
cfg.Author = "github.com/google/ko"

if cfg.Config.Labels == nil {
Expand All @@ -771,9 +826,9 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, baseRef name.Refe
return image, nil
}

// Append appDir to the PATH environment variable, if it exists. Otherwise,
// set the PATH environment variable to appDir.
func updatePath(cf *v1.ConfigFile) {
// Append appPath to the PATH environment variable, if it exists. Otherwise,
// set the PATH environment variable to appPath.
func updatePath(cf *v1.ConfigFile, appPath string) {
for i, env := range cf.Config.Env {
parts := strings.SplitN(env, "=", 2)
if len(parts) != 2 {
Expand All @@ -782,14 +837,14 @@ func updatePath(cf *v1.ConfigFile) {
}
key, value := parts[0], parts[1]
if key == "PATH" {
value = fmt.Sprintf("%s:%s", value, appDir)
value = fmt.Sprintf("%s:%s", value, appPath)
cf.Config.Env[i] = "PATH=" + value
return
}
}

// If we get here, we never saw PATH.
cf.Config.Env = append(cf.Config.Env, "PATH="+appDir)
cf.Config.Env = append(cf.Config.Env, "PATH="+appPath)
}

// Build implements build.Interface
Expand Down
2 changes: 1 addition & 1 deletion pkg/build/gobuild_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ func validateImage(t *testing.T, img v1.Image, baseLayers int64, creationTime v1
pathValue := strings.TrimPrefix(envVar, "PATH=")
pathEntries := strings.Split(pathValue, ":")
for _, pathEntry := range pathEntries {
if pathEntry == appDir {
if pathEntry == "/ko-app/test" {
found = true
}
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/publish/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ func (d *demon) Publish(ctx context.Context, br build.Result, s string) (name.Re
}

log.Printf("Loading %v", digestTag)
if _, err := daemon.Write(digestTag, img, d.getOpts(ctx)...); err != nil {
if resp, err := daemon.Write(digestTag, img, d.getOpts(ctx)...); err != nil {
log.Println("daemon.Write response: ", resp)
return nil, err
}
log.Printf("Loaded %v", digestTag)
Expand Down
Loading

0 comments on commit 6905332

Please sign in to comment.