Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ cleanroom exec --expose 15432:5432 -- postgres

# Local HTTPS: usually https://buildkite.cleanroom.localhost:8143
cleanroom exec --expose-https buildkite:3000 -- mise exec -- npm run dev

# Or load HTTPS route names from expose.https in cleanroom.yaml
cleanroom exec --expose-https -- mise exec -- npm run dev
```

The installer starts the Cleanroom daemon. It does not install the macOS
Expand All @@ -106,6 +109,24 @@ sudo cleanroom dns install
listener is started by `--expose-https` and `cleanroom expose` while exposures
are active.

Configured HTTPS routes can enumerate full local hostnames or use a shared base:

```yaml
expose:
https:
base: "{sandbox_id}.cleanroom.localhost"
routes:
- port: 3000
hosts:
- "{base}"
- "*.{base}"
- "*.*.{base}"
```

`cleanroom dns install` manages `cleanroom.localhost`. Routes under another
local suffix, such as `{sandbox_id}.localhost`, also work when that suffix
resolves to the Cleanroom DNS listener.

Run Docker inside the microVM when the workload needs it:

```yaml
Expand Down
23 changes: 23 additions & 0 deletions docs/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,29 @@ The URL is usually:
https://buildkite.cleanroom.localhost:8143
```

For projects that need several hostnames or wildcard labels, declare the routes
in `cleanroom.yaml` and pass `--expose-https` without a value:

```yaml
expose:
https:
base: "{sandbox_id}.cleanroom.localhost"
routes:
- port: 3000
hosts:
- "{base}"
- "*.{base}"
- "*.*.{base}"
```

```bash
cleanroom exec --expose-https -- npm run dev
```

`cleanroom dns install` manages `cleanroom.localhost`. Routes under another
local suffix, such as `{sandbox_id}.localhost`, also work when that suffix
resolves to the Cleanroom DNS listener.

For an existing sandbox, use:

```bash
Expand Down
11 changes: 5 additions & 6 deletions examples/multi-host-routing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,13 @@ cd examples/multi-host-routing
mise exec -- cleanroom policy validate
```

Start the example with three exact HTTPS hosts pointing at the same guest port:
Start the example with the HTTPS hosts declared in `cleanroom.yaml` pointing at
the same guest port:

```bash
mise exec -- cleanroom exec \
--backend darwin-vz \
--expose-https example:80 \
--expose-https example-app:80 \
--expose-https example-s3:80 \
--expose-https \
-- sh -lc 'cd /workspace/examples/multi-host-routing && sh ./start.sh'
```

Expand All @@ -65,8 +64,8 @@ If Cleanroom chooses a different HTTPS listener port, pass that port to

## What This Exercises

- exact route registration for hosts covered by the existing
`*.cleanroom.localhost` TLS certificate
- configured exact route registration for hosts covered by the local Cleanroom
certificate authority
- guest-side host-based virtual hosting in `nginx`
- preserved `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`,
`X-Forwarded-Port`, and `X-Forwarded-For`
Expand Down
9 changes: 9 additions & 0 deletions examples/multi-host-routing/cleanroom.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
version: 1

expose:
https:
routes:
- port: 80
hosts:
- example.cleanroom.localhost
- example-app.cleanroom.localhost
- example-s3.cleanroom.localhost

sandbox:
image:
ref: ghcr.io/buildkite/cleanroom-base/debian@sha256:28c3f638fabe1ed780f87b82cfb0c6dda2549c86b9e4edbe519e8250243411c5
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
type policyLoader interface {
LoadAndCompile(cwd string) (*policy.CompiledPolicy, string, error)
LoadRepository(cwd string) (policy.RepositoryConfig, string, error)
LoadExpose(cwd string) (policy.ExposeConfig, string, error)
}

type runtimeContext struct {
Expand Down Expand Up @@ -129,6 +130,7 @@ type hasExitCode interface {
}

func Run(args []string, version string) (runErr error) {
args = normalizeBareExposeHTTPSArgs(args)
runtimeCtx := &runtimeContext{
Stdout: os.Stdout,
Stderr: os.Stderr,
Expand Down
123 changes: 123 additions & 0 deletions internal/cli/configured_exposure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package cli

import (
"errors"
"fmt"
"strings"

cleanroomv1 "github.com/buildkite/cleanroom/internal/gen/cleanroom/v1"
"github.com/buildkite/cleanroom/internal/policy"
)

func normalizeBareExposeHTTPSArgs(args []string) []string {
if len(args) == 0 {
return nil
}
out := append([]string(nil), args...)
for i, arg := range out {
if arg != "--expose-https" {
continue
}
if i+1 >= len(out) || out[i+1] == "--" || strings.HasPrefix(out[i+1], "-") {
out[i] = "--expose-https=" + configuredHTTPSExposureSpec
Comment thread
lox marked this conversation as resolved.
}
}
return out
}

func resolveRequestedExposures(ctx *runtimeContext, cwd, sandboxID string, requested []*cleanroomv1.PortExposure) ([]*cleanroomv1.PortExposure, error) {
if !hasConfiguredHTTPSExposure(requested) {
return requested, nil
}
if ctx == nil || ctx.Loader == nil {
return nil, errors.New("configured --expose-https requires a policy loader")
}
cfg, _, err := ctx.Loader.LoadExpose(cwd)
if err != nil {
if errors.Is(err, policy.ErrPolicyNotFound) {
return nil, errors.New("configured --expose-https requires expose.https in cleanroom.yaml")
}
return nil, err
}
configured, err := expandConfiguredHTTPSExposures(cfg.HTTPS, sandboxID)
if err != nil {
return nil, err
}

resolved := make([]*cleanroomv1.PortExposure, 0, len(requested)+len(configured))
for _, req := range requested {
if isConfiguredHTTPSExposure(req) {
resolved = append(resolved, configured...)
continue
}
resolved = append(resolved, req)
}
return resolved, nil
}

func hasConfiguredHTTPSExposure(requested []*cleanroomv1.PortExposure) bool {
for _, req := range requested {
if isConfiguredHTTPSExposure(req) {
return true
}
}
return false
}

func isConfiguredHTTPSExposure(req *cleanroomv1.PortExposure) bool {
return req != nil &&
strings.TrimSpace(req.GetProtocol()) == exposureProtocolHTTPS &&
strings.TrimSpace(req.GetName()) == configuredHTTPSExposureSpec &&
req.GetGuestPort() == 0
}

func expandConfiguredHTTPSExposures(cfg policy.ExposeHTTPSConfig, sandboxID string) ([]*cleanroomv1.PortExposure, error) {
sandboxID = strings.TrimSpace(strings.ToLower(sandboxID))
if sandboxID == "" {
return nil, errors.New("configured --expose-https requires a sandbox id")
}
if cfg.IsZero() {
return nil, errors.New("configured --expose-https requires expose.https in cleanroom.yaml")
}
base := strings.TrimSpace(strings.ToLower(cfg.Base))
if base != "" {
base = expandExposeTemplate(base, sandboxID, "")
base = strings.TrimSpace(strings.ToLower(base))
}
if strings.TrimSpace(cfg.Base) != "" && base == "" {
return nil, errors.New("expose.https.base expanded to an empty host")
}

exposures := make([]*cleanroomv1.PortExposure, 0)
for i, route := range cfg.Routes {
if route.Port < 1 || route.Port > 65535 {
return nil, fmt.Errorf("expose.https.routes[%d].port must be in range 1-65535", i)
}
if len(route.Hosts) == 0 {
return nil, fmt.Errorf("expose.https.routes[%d].hosts must include at least one host", i)
}
for j, host := range route.Hosts {
if strings.Contains(host, "{base}") && base == "" {
return nil, fmt.Errorf("expose.https.routes[%d].hosts[%d] uses {base} but expose.https.base is empty", i, j)
}
host = expandExposeTemplate(host, sandboxID, base)
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return nil, fmt.Errorf("expose.https.routes[%d].hosts[%d] expanded to an empty host", i, j)
}
exposures = append(exposures, &cleanroomv1.PortExposure{
Comment thread
lox marked this conversation as resolved.
Protocol: exposureProtocolHTTPS,
GuestPort: int32(route.Port),
Name: host,
})
}
}
return exposures, nil
}

func expandExposeTemplate(value, sandboxID, base string) string {
value = strings.ReplaceAll(value, "{sandbox_id}", sandboxID)
value = strings.ReplaceAll(value, "{container_id}", sandboxID)
value = strings.ReplaceAll(value, "{base}", base)
return value
}
139 changes: 139 additions & 0 deletions internal/cli/configured_exposure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package cli

import (
"errors"
"reflect"
"strings"
"testing"

cleanroomv1 "github.com/buildkite/cleanroom/internal/gen/cleanroom/v1"
"github.com/buildkite/cleanroom/internal/policy"
)

func TestNormalizeBareExposeHTTPSArgs(t *testing.T) {
t.Parallel()

tests := []struct {
name string
args []string
want []string
}{
{
name: "bare before command separator",
args: []string{"exec", "--expose-https", "--", "npm", "run", "dev"},
want: []string{"exec", "--expose-https=" + configuredHTTPSExposureSpec, "--", "npm", "run", "dev"},
},
{
name: "bare at end",
args: []string{"create", "--expose-https"},
want: []string{"create", "--expose-https=" + configuredHTTPSExposureSpec},
},
{
name: "explicit value",
args: []string{"create", "--expose-https", "buildkite:3000"},
want: []string{"create", "--expose-https", "buildkite:3000"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := normalizeBareExposeHTTPSArgs(tt.args)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("unexpected args: got %v want %v", got, tt.want)
}
if len(tt.args) > 0 && &got[0] == &tt.args[0] {
t.Fatal("expected normalized args to be a copy")
}
})
}
}

func TestExpandConfiguredHTTPSExposures(t *testing.T) {
t.Parallel()

got, err := expandConfiguredHTTPSExposures(policy.ExposeHTTPSConfig{
Base: "{sandbox_id}.localhost",
Routes: []policy.ExposeHTTPSRoute{{
Port: 3000,
Hosts: []string{"{base}", "*.{base}", "*.*.{base}"},
}},
}, "Cr-123")
if err != nil {
t.Fatalf("expandConfiguredHTTPSExposures returned error: %v", err)
}

want := []*cleanroomv1.PortExposure{
{Protocol: exposureProtocolHTTPS, GuestPort: 3000, Name: "cr-123.localhost"},
{Protocol: exposureProtocolHTTPS, GuestPort: 3000, Name: "*.cr-123.localhost"},
{Protocol: exposureProtocolHTTPS, GuestPort: 3000, Name: "*.*.cr-123.localhost"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected exposures: got %v want %v", got, want)
}
}

func TestResolveRequestedExposuresLoadsConfiguredHTTPS(t *testing.T) {
t.Parallel()

loader := &configuredExposureLoader{
cfg: policy.ExposeConfig{HTTPS: policy.ExposeHTTPSConfig{
Base: "{container_id}.localhost",
Routes: []policy.ExposeHTTPSRoute{{
Port: 3000,
Hosts: []string{"{base}", "*.{base}"},
}},
}},
}
got, err := resolveRequestedExposures(&runtimeContext{Loader: loader}, "/repo", "sandbox-1", []*cleanroomv1.PortExposure{
{Protocol: exposureProtocolTCP, HostPort: 5432, GuestPort: 5432},
{Protocol: exposureProtocolHTTPS, Name: configuredHTTPSExposureSpec},
})
if err != nil {
t.Fatalf("resolveRequestedExposures returned error: %v", err)
}
if got, want := loader.cwd, "/repo"; got != want {
t.Fatalf("unexpected loader cwd: got %q want %q", got, want)
}
if got, want := len(got), 3; got != want {
t.Fatalf("unexpected exposure count: got %d want %d", got, want)
}
if got, want := got[1].GetName(), "sandbox-1.localhost"; got != want {
t.Fatalf("unexpected first configured host: got %q want %q", got, want)
}
if got, want := got[2].GetName(), "*.sandbox-1.localhost"; got != want {
t.Fatalf("unexpected second configured host: got %q want %q", got, want)
}
}

func TestResolveRequestedExposuresRequiresConfiguredHTTPS(t *testing.T) {
t.Parallel()

_, err := resolveRequestedExposures(&runtimeContext{Loader: &configuredExposureLoader{}}, "/repo", "sandbox-1", []*cleanroomv1.PortExposure{
{Protocol: exposureProtocolHTTPS, Name: configuredHTTPSExposureSpec},
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "requires expose.https") {
t.Fatalf("unexpected error: %v", err)
}
}

type configuredExposureLoader struct {
cfg policy.ExposeConfig
cwd string
}

func (l *configuredExposureLoader) LoadAndCompile(string) (*policy.CompiledPolicy, string, error) {
return nil, "", errors.New("LoadAndCompile should not be called")
}

func (l *configuredExposureLoader) LoadRepository(string) (policy.RepositoryConfig, string, error) {
return policy.RepositoryConfig{}, "", errors.New("LoadRepository should not be called")
}

func (l *configuredExposureLoader) LoadExpose(cwd string) (policy.ExposeConfig, string, error) {
l.cwd = cwd
return l.cfg, "/repo/cleanroom.yaml", nil
}
Loading