Skip to content

Commit

Permalink
Move agent's run.sh into Go
Browse files Browse the repository at this point in the history
Since the nonroot distroless image doesn't have a shell, we can't use
run.sh to copy the porter config files into PORTER_HOME at container
start. I have implemented that in Go (sorry it's a lot vs what good ole
cp did for us under the hood).

One trick is that when /porter-config is mounted into the container by
k8s, it uses symlinks like this:

/porter-config
  ..data/porter.config
  porter.config -> ..data/porter.config

So it's not a straightforward as you'd think at first glance.

Signed-off-by: Carolyn Van Slyck <me@carolynvanslyck.com>
  • Loading branch information
carolynvs committed Nov 22, 2021
1 parent 59a9d4c commit eeca060
Show file tree
Hide file tree
Showing 15 changed files with 260 additions and 50 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*

!bin/dev/porter-linux-amd64
!bin/dev/agent-linux-amd64
!bin/mixins/exec/dev/exec-linux-amd64
7 changes: 4 additions & 3 deletions build/images/agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ ARG PORTER_VERSION
ARG REGISTRY
FROM $REGISTRY/porter:$PORTER_VERSION

# This is where files that need to be copied into /root/.porter/ should be mounted
# This is where files that need to be copied into /app/.porter/ should be mounted
VOLUME /porter-config

COPY run.sh /
ENV PORTER_HOME /app/.porter
COPY --chown=65532:65532 bin/dev/agent-linux-amd64 /app/.porter/agent

ENTRYPOINT ["/run.sh"]
ENTRYPOINT ["/app/.porter/agent"]
19 changes: 0 additions & 19 deletions build/images/agent/run.sh

This file was deleted.

27 changes: 10 additions & 17 deletions build/images/client/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
FROM alpine:3 as builder
WORKDIR /app/.porter

RUN mkdir -p /root/.porter/runtimes && \
mkdir -p /root/.porter/mixins/exec/runtimes
RUN mkdir runtimes && \
mkdir -p mixins/exec/runtimes

COPY bin/dev/porter-linux-amd64 /root/.porter/porter
COPY bin/mixins/exec/dev/exec-linux-amd64 /root/.porter/mixins/exec/exec
RUN ln -s /root/.porter/porter /root/.porter/runtimes/porter-runtime && \
ln -s /root/.porter/mixins/exec/exec /root/.porter/mixins/exec/runtimes/exec-runtime

RUN porter mixin install kubernetes && \
porter mixin install helm && \
porter mixin install arm && \
porter mixin install terraform && \
porter mixin install az && \
porter mixin install aws && \
porter mixin install gcloud && \
porter plugin install azure && \
porter plugin install kubernetes
# Only install porter and the exec mixin, everything else
# must be mounted into the container
COPY bin/dev/porter-linux-amd64 porter
COPY bin/mixins/exec/dev/exec-linux-amd64 mixins/exec/exec
RUN ln -s /app/.porter/porter runtimes/porter-runtime && \
ln -s /app/.porter/mixins/exec/exec mixins/exec/runtimes/exec-runtime

# Copy the porter installation into a distroless container without root
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=builder /root/.porter /app/.porter
COPY --from=builder --chown=65532:65532 /app/.porter /app/.porter
ENV PATH "$PATH:/app/.porter"

ENTRYPOINT ["/app/.porter/porter"]
30 changes: 30 additions & 0 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package main

import (
"fmt"
"os"

"get.porter.sh/porter/pkg/agent"
)

// The porter agent wraps the porter cli,
// handling coping config files from a mounted
// volume into PORTER_HOME
func main() {
porterHome := os.Getenv("PORTER_HOME")
if porterHome == "" {
porterHome = "/app/.porter"
}
porterConfig := os.Getenv("PORTER_CONFIG")
if porterConfig == "" {
porterConfig = "/porter-config"
}
err, run := agent.Execute(os.Args[1:], porterHome, porterConfig)
if err != nil {
if !run {
fmt.Fprintln(os.Stderr, err)
}

os.Exit(1)
}
}
11 changes: 5 additions & 6 deletions docs/content/docker-images/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: How to use the getporter/porter Docker image
---

The [getporter/porter][porter] Docker image provides the Porter client installed in a
container.
container. Mixins and plugins are **not** installed by default and must be mounted into /root/.porter.

It has tags that match what is available from our [install](/install/) page:
latest, canary and specific versions such as v0.20.0-beta.1.
Expand All @@ -16,9 +16,8 @@ latest, canary and specific versions such as v0.20.0-beta.1.
* The `ENTRYPOINT` is set to `porter`. To change this, you can use
`--entrypoint`, e.g. `docker run --rm -it --entrypoint /bin/sh porter`.
* Don't mount the entire Porter home directory, because that's where the porter
binary is located. Instead, mount individual directories such as claims,
results and outputs (all three are used to record data for an installation)
or credentials and parameters, if needed. Otherwise you will get an error
binary is located. Instead, mount individual directories such as mixins, claims,
results, outputs, etc if needed. Otherwise, you will get an error
like `exec user process caused "exec format error"`.

## Examples
Expand Down Expand Up @@ -59,7 +58,7 @@ docker run -it --rm \
```

### Install
Finally let's install a bundle:
Finally, let's install a bundle:

```
$ docker run -it --rm \
Expand Down Expand Up @@ -91,4 +90,4 @@ NAME CREATED MODIFIED LAST ACTION LAST STATUS
hello 2 minutes ago 2 minutes ago install success
```

[porter]: https://hub.docker.com/r/getporter/porter/tags
[porter]: https://hub.docker.com/r/getporter/porter/tags
15 changes: 10 additions & 5 deletions magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import (
const (
PKG = "get.porter.sh/porter"
GoVersion = ">=1.16"
mixinsURL = "https://cdn.porter.sh/mixins/"
)

var must = shx.CommandBuilder{StopOnError: true}
Expand All @@ -51,9 +50,9 @@ func CheckGoVersion() {
tools.EnforceGoVersion(GoVersion)
}

// Build the porter and the exec mixin
// Builds all code artifacts in the repository
func Build() {
mg.SerialDeps(BuildPorter, DocsGen, BuildExecMixin)
mg.SerialDeps(BuildPorter, DocsGen, BuildExecMixin, BuildAgent)
mg.Deps(GetMixins)
}

Expand All @@ -67,6 +66,12 @@ func BuildExecMixin() {
mgx.Must(releases.BuildAll(PKG, "exec", "bin/mixins/exec"))
}

// Build the porter agent
func BuildAgent() {
// the agent is only used embedded in a docker container, so we only build for linux
releases.XBuild(PKG, "agent", "bin", "linux", "amd64")
}

// Cross-compile porter and the exec mixin
func XBuildAll() {
mg.Deps(XBuildPorter, XBuildMixins)
Expand Down Expand Up @@ -245,7 +250,7 @@ func buildImages(registry string, info mage.GitMetadata) {

// porter-agent does a FROM porter so they can't go in parallel
img = fmt.Sprintf("%s/porter-agent:%s", registry, info.Version)
err = shx.RunV("docker", "build", "-t", img, "--build-arg", "PORTER_VERSION="+info.Version, "--build-arg", "REGISTRY="+registry, "-f", "build/images/agent/Dockerfile", "build/images/agent")
err = shx.RunV("docker", "build", "-t", img, "--build-arg", "PORTER_VERSION="+info.Version, "--build-arg", "REGISTRY="+registry, "-f", "build/images/agent/Dockerfile", ".")
if err != nil {
return err
}
Expand Down Expand Up @@ -284,7 +289,7 @@ func LocalPorterAgentBuild() {
// Force the image to be pushed to the registry even though it's a local dev build.
os.Setenv("PORTER_FORCE_PUBLISH", "true")

mg.SerialDeps(XBuildPorter, PublishImages)
mg.SerialDeps(XBuildPorter, BuildAgent, PublishImages)
}

// Only push tagged versions, canary and latest
Expand Down
134 changes: 134 additions & 0 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package agent

import (
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"

"golang.org/x/sync/errgroup"
)

// allow the tests to capture output
var (
stdout io.Writer = os.Stdout
stderr io.Writer = os.Stderr
)

// The porter agent wraps the porter cli,
// handling coping config files from a mounted
// volume into PORTER_HOME
// Returns any errors and if the porter command was executed
func Execute(porterCommand []string, porterHome string, porterConfig string) (error, bool) {
porter := porterHome + "/porter"

// Copy config files into PORTER_HOME
err := filepath.Walk(porterConfig, func(path string, info fs.FileInfo, err error) error {
if info.IsDir() {
return nil
}

// Determine the relative path of the file we are copying
relPath, err := filepath.Rel(porterConfig, path)
if err != nil {
return err
}

// Skip hidden files, these are injected by k8s when the config volume is mounted
if strings.HasPrefix(relPath, ".") {
return nil
}

// If the files are symlinks then resolve them
// /porter-config
// - config.toml (symlink to the file in ..data)
// - ..data/config.toml
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
return err
}

resolvedInfo, err := os.Stat(resolvedPath)
if err != nil {
return err
}

return copyConfig(relPath, resolvedPath, resolvedInfo, porterHome)
})
if err != nil {
return err, false
}

// Remind everyone the version of Porter we are using
cmd := exec.Command(porter, "version")
cmd.Stdout = stdout
cmd.Stderr = stderr

// Run the specified porter command
fmt.Fprintf(stderr, "porter %s\n", strings.Join(porterCommand, " "))
cmd = exec.Command(porter, porterCommand...)
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
return err, false
}
return cmd.Wait(), true
}

func copyConfig(relPath string, configFile string, fi os.FileInfo, porterHome string) error {
destFile := filepath.Join(porterHome, relPath)
fmt.Fprintln(stderr, "Loading configuration", relPath, "into", destFile)
src, err := os.OpenFile(configFile, os.O_RDONLY, 0)
if err != nil {
return err
}
defer src.Close()

if err = os.MkdirAll(filepath.Dir(destFile), 0700); err != nil {
return err
}
dest, err := os.OpenFile(destFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fi.Mode())
if err != nil {
return err
}
defer dest.Close()

if !isExecutable(fi.Mode()) {
// Copy the file and write out its content at the same time
wg := errgroup.Group{}
pr, pw := io.Pipe()
tr := io.TeeReader(src, pw)

// Copy the File
wg.Go(func() error {
defer pw.Close()

_, err = io.Copy(dest, tr)
return err
})

// Print out the contents of the transferred file only if it's not executable
wg.Go(func() error {
// read from the PipeReader to stdout
_, err := io.Copy(stderr, pr)

// Pad with whitespace so it's easier to see the file contents
fmt.Fprintf(stderr, "\n\n")
return err
})

return wg.Wait()
}

// Just copy the file if it's binary, don't print it out
_, err = io.Copy(dest, src)
return err
}

func isExecutable(mode os.FileMode) bool {
return mode&0111 != 0
}
60 changes: 60 additions & 0 deletions pkg/agent/agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// +build integration

package agent

import (
"bytes"
"io/ioutil"
"os"
"path/filepath"
"testing"

"github.com/carolynvs/magex/shx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestExecute(t *testing.T) {
home := makeTestPorterHome(t)
defer os.RemoveAll(home)
cfg := "testdata"

stdoutBuff := &bytes.Buffer{}
stderrBuff := &bytes.Buffer{}
stdout = stdoutBuff
stderr = stderrBuff

err, run := Execute([]string{"help"}, home, cfg)
require.NoError(t, err)
assert.True(t, run, "porter should have run")
gotStderr := stderrBuff.String()
assert.Contains(t, stdoutBuff.String(), "Usage:", "porter command output should be printed")

contents, err := os.ReadFile(filepath.Join(home, "config.toml"))
require.NoError(t, err)
wantTomlContents := "# I am a porter config"
assert.Equal(t, wantTomlContents, string(contents))
assert.Contains(t, gotStderr, wantTomlContents, "config file contents should be printed to stderr")

contents, err = os.ReadFile(filepath.Join(home, "config.json"))
require.NoError(t, err)
wantJsonContents := "{}"
assert.Equal(t, wantJsonContents, string(contents))
assert.Contains(t, gotStderr, wantJsonContents, "config file contents should be printed to stderr")

contents, err = os.ReadFile(filepath.Join(home, "a-binary"))
require.NoError(t, err)
wantBinaryContents := "binary contents"
assert.Equal(t, wantBinaryContents, string(contents))
assert.NotContains(t, gotStderr, wantBinaryContents, "binary file contents should NOT be printed")

_, err = os.Stat(filepath.Join(home, ".hidden"))
require.True(t, os.IsNotExist(err), "hidden files should not be copied")
}

func makeTestPorterHome(t *testing.T) string {
home, err := ioutil.TempDir("", "porter-home")
require.NoError(t, err)
require.NoError(t, shx.Copy("../../bin/porter", home))
return home
}
1 change: 1 addition & 0 deletions pkg/agent/testdata/..data/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions pkg/agent/testdata/..data/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# I am a porter config
Loading

0 comments on commit eeca060

Please sign in to comment.