Skip to content
Merged
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
56 changes: 56 additions & 0 deletions pkg/runner/policy_gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package runner

import (
"context"
"sync"
)

// PolicyGate is called before server creation operations to allow external
// policy enforcement. Additional methods (e.g., CheckStopServer) may be added
// in future issues; downstream implementations should embed a NoopPolicyGate
// to remain forward-compatible.
type PolicyGate interface {
// CheckCreateServer is called before a local workload container is set up.
// Return a non-nil error to block server creation.
CheckCreateServer(ctx context.Context, cfg *RunConfig) error
}

// NoopPolicyGate is a policy gate that allows all operations. Downstream
// implementations should embed this struct to remain forward-compatible when
// new methods are added to the PolicyGate interface.
type NoopPolicyGate struct{}

// CheckCreateServer implements PolicyGate by allowing all create operations.
func (NoopPolicyGate) CheckCreateServer(_ context.Context, _ *RunConfig) error {
return nil
}

// allowAllGate is the default policy gate used when no gate has been registered.
type allowAllGate struct {
NoopPolicyGate
}

var (
policyGateMu sync.Mutex
policyGate PolicyGate = allowAllGate{}
)

// RegisterPolicyGate replaces the active policy gate with g. It is safe to
// call from multiple goroutines, though it is intended to be called once at
// program startup before any runners are created.
func RegisterPolicyGate(g PolicyGate) {
policyGateMu.Lock()
defer policyGateMu.Unlock()
policyGate = g
}

// activePolicyGate returns the currently registered policy gate under the
// package-level mutex.
func activePolicyGate() PolicyGate {
policyGateMu.Lock()
defer policyGateMu.Unlock()
return policyGate
}
89 changes: 89 additions & 0 deletions pkg/runner/policy_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package runner

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

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

gate := allowAllGate{}
err := gate.CheckCreateServer(context.Background(), NewRunConfig())
assert.NoError(t, err)
}

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

gate := NoopPolicyGate{}
err := gate.CheckCreateServer(context.Background(), NewRunConfig())
assert.NoError(t, err)
}

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

// Save and restore the original gate after the test.
policyGateMu.Lock()
original := policyGate
policyGateMu.Unlock()
t.Cleanup(func() {
policyGateMu.Lock()
policyGate = original
policyGateMu.Unlock()
})

sentinel := errors.New("blocked by test policy")
denyGate := &errorPolicyGate{err: sentinel}

RegisterPolicyGate(denyGate)

got := activePolicyGate()
require.Equal(t, denyGate, got)

err := got.CheckCreateServer(context.Background(), NewRunConfig())
require.ErrorIs(t, err, sentinel)
}

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

// Save and restore gate so parallel tests are not affected.
policyGateMu.Lock()
original := policyGate
policyGateMu.Unlock()
t.Cleanup(func() {
policyGateMu.Lock()
policyGate = original
policyGateMu.Unlock()
})

// Reset to the package default for this subtest.
policyGateMu.Lock()
policyGate = allowAllGate{}
policyGateMu.Unlock()

got := activePolicyGate()
assert.IsType(t, allowAllGate{}, got)

err := got.CheckCreateServer(context.Background(), NewRunConfig())
assert.NoError(t, err)
}

// errorPolicyGate is a test helper that always returns the configured error.
type errorPolicyGate struct {
NoopPolicyGate
err error
}

func (g *errorPolicyGate) CheckCreateServer(_ context.Context, _ *RunConfig) error {
return g.err
}
5 changes: 5 additions & 0 deletions pkg/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ func (r *Runner) Run(ctx context.Context) error {
var setupResult *runtime.SetupResult

if r.Config.RemoteURL == "" {
// Check policy gate before creating the server
if err := activePolicyGate().CheckCreateServer(ctx, r.Config); err != nil {
return fmt.Errorf("server creation blocked by policy: %w", err)
}

// For local workloads, deploy the container using runtime.Setup first
var scalingConfig *rt.ScalingConfig
if r.Config.ScalingConfig != nil {
Expand Down
Loading