Skip to content
This repository has been archived by the owner on Mar 16, 2024. It is now read-only.

Commit

Permalink
add: user directive in Acornfile (e.g. user: "1000:1000") (#718) (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
iwilltry42 committed Nov 21, 2023
1 parent 5e043f7 commit a822ff9
Show file tree
Hide file tree
Showing 10 changed files with 360 additions and 3 deletions.
5 changes: 5 additions & 0 deletions pkg/apis/api.acorn.io/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions pkg/apis/internal.acorn.io/v1/appspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ type PolicyRule struct {
Scopes []string `json:"scopes,omitempty"`
}

type UserContext struct {
UID int64 `json:"uid,omitempty"`
GID int64 `json:"gid,omitempty"`
}

func slicePermutation(in []string) (result [][]string) {
if len(in) == 0 {
return [][]string{nil}
Expand Down Expand Up @@ -640,6 +645,7 @@ type Container struct {
Permissions *Permissions `json:"permissions,omitempty"`
ComputeClass *string `json:"class,omitempty"`
Memory *int64 `json:"memory,omitempty"`
UserContext *UserContext `json:"user,omitempty"`

// Metrics is available on containers and jobs, but not sidecars
Metrics MetricsDef `json:"metrics,omitempty"`
Expand Down
44 changes: 44 additions & 0 deletions pkg/apis/internal.acorn.io/v1/unmarshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,50 @@ type routeTarget struct {
TargetServiceName string `json:"targetServiceName,omitempty"`
}

func (in *UserContext) UnmarshalJSON(data []byte) error {
if !isString(data) {
var d int64
if err := json.Unmarshal(data, &d); err == nil {
in.UID = d
in.GID = d
return nil
}
type userContext UserContext
return json.Unmarshal(data, (*userContext)(in))
}

s, err := parseString(data)
if err != nil {
return err
}
parts := strings.Split(s, ":")

if len(parts) > 2 {
return fmt.Errorf("invalid user context has extra part: %s", s)
}

uid, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return fmt.Errorf("failed to parse uid %s in %s: %w", parts[0], s, err)
}
in.UID = uid
in.GID = uid

if len(parts) == 2 {
gid, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return fmt.Errorf("failed to parse gid %s in %s: %w", parts[1], s, err)
}
in.GID = gid
}

if in.UID < 0 || in.GID < 0 {
return fmt.Errorf("invalid user context uid or gid < 0: %s", s)
}

return nil
}

func (in *routeTarget) UnmarshalJSON(data []byte) error {
if !isString(data) {
type routeTargetType routeTarget
Expand Down
147 changes: 147 additions & 0 deletions pkg/apis/internal.acorn.io/v1/unmarshal_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package v1

import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"

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

func TestParseHostnameBinding(t *testing.T) {
Expand Down Expand Up @@ -43,3 +48,145 @@ func TestParseEnv(t *testing.T) {
Value: "y111",
}, f[1])
}

func TestUserContextUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
data []byte
expected *UserContext
wantErr bool
}{
{
name: "valid uid+gid",
data: []byte(`"123:456"`),
expected: &UserContext{
UID: 123,
GID: 456,
},
wantErr: false,
},
{
name: "valid uid only int",
data: []byte(`123`),
expected: &UserContext{
UID: 123,
GID: 123,
},
wantErr: false,
},
{
name: "invalid uid+gid no quotes",
data: []byte(`123:456`),
expected: nil,
wantErr: true,
},
{
name: "invalid uid",
data: []byte(`"abc:456"`),
expected: nil,
wantErr: true,
},
{
name: "invalid gid",
data: []byte(`"123:def"`),
expected: nil,
wantErr: true,
},
{
name: "valid uid only string",
data: []byte(`"123"`),
expected: &UserContext{
UID: 123,
GID: 123,
},
wantErr: false,
},
{
name: "invalid extra field",
data: []byte(`"1000:1000:1000"`),
expected: nil,
wantErr: true,
},
{
name: "invalid missing uid",
data: []byte(`":1000"`),
expected: nil,
wantErr: true,
},
{
name: "invalid missing gid",
data: []byte(`"1000:"`),
expected: nil,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var uc UserContext
err := uc.UnmarshalJSON(tt.data)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, tt.expected)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, &uc)
}
})
}
}

func FuzzUserContextUnmarshalJSON(f *testing.F) {
// Add seed corpus
f.Add([]byte(`123`)) // Simple integer
f.Add([]byte(`"123:456"`)) // String with UID and GID
f.Add([]byte(`":123"`)) // String with UID missing
f.Add([]byte(`"123:"`)) // String with GID missing
f.Add([]byte(`"not a number"`)) // Invalid string
f.Add([]byte(`{"UID": 456, "GID": 789}`)) // JSON object
f.Add([]byte(`{"invalid": "json"}`)) // Invalid JSON for UserContext
f.Add([]byte(`["not", "object"]`)) // Invalid type

f.Fuzz(func(t *testing.T, data []byte) {
var userContext UserContext
err := userContext.UnmarshalJSON(data)

if isString(data) {
// If the data is string, check for valid UID/GID parsing or error
s, _ := parseString(data)
parts := strings.Split(s, ":")
if len(parts) == 1 || len(parts) == 2 {
_, errUID := strconv.ParseInt(parts[0], 10, 64)
if len(parts) == 2 {
_, errGID := strconv.ParseInt(parts[1], 10, 64)
if errUID != nil || errGID != nil {
require.Error(t, err, fmt.Errorf("Expected error for invalid UID/GID in string but got nil"))
}
} else if errUID != nil {
require.Error(t, err, fmt.Errorf("Expected error for invalid UID in string but got nil"))
}
}
} else {
// Check for valid int or JSON
var tempInt int64
if errUnmarshal := json.Unmarshal(data, &tempInt); errUnmarshal == nil {
// If it's a valid integer, expect no error from UnmarshalJSON
require.NoError(t, err, fmt.Errorf("Expected no error for valid integer input but got %w", err))
if userContext.UID != tempInt || userContext.GID != tempInt {
t.Errorf("Expected UID and GID to be %v, got UID: %v, GID: %v", tempInt, userContext.UID, userContext.GID)
}
} else {
// Check if it's valid JSON that can be unmarshalled
var temp interface{}
if json.Unmarshal(data, &temp) == nil {
_, ok := temp.(map[string]interface{})
if !ok {
require.Error(t, err, fmt.Errorf("Expected error for non-object JSON but got nil"))
}
} else {
require.Error(t, err, fmt.Errorf("Expected error for invalid JSON but got nil"))
}
}
}
})
}
20 changes: 20 additions & 0 deletions pkg/apis/internal.acorn.io/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pkg/appdefinition/acornfile-schema.acorn
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,15 @@ let types: {
match "probes|probe": Probes
match "depends[oO]n|depends_on|consumes": string || [string]
match "mem|memory": int
user?: User
permissions?: {
rules: [RuleSpec]
clusterRules?: [ClusterRuleSpec]
}
}

User: (int > 0) || (string =~ "^[0-9]+(:[0-9]+)?$")

ShortVolumeRef: string =~ "^[a-z][-a-z0-9]*$"
VolumeRef: string =~ "^volume://.+$"
EphemeralRef: string =~ "^ephemeral://.*$|^$"
Expand Down
49 changes: 49 additions & 0 deletions pkg/appdefinition/appdefinition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3058,3 +3058,52 @@ func TestNestedScopedLabels(t *testing.T) {
}}`))
assert.Error(t, err)
}

func TestUserContext(t *testing.T) {
appImage, err := NewAppDefinition([]byte(`containers: foo: {
image: "foo:latest"
user: "1000:2000"
}`))
if err != nil {
t.Fatal(err)
}

appSpec, err := appImage.AppSpec()
if err != nil {
errors.Print(os.Stderr, err, nil)
t.Fatal(err)
}

require.Equal(t, &v1.UserContext{UID: 1000, GID: 2000}, appSpec.Containers["foo"].UserContext)

// GID default to UID
nAppImage, err := NewAppDefinition([]byte(`containers: foo: {
image: "foo:latest"
user: "1000"
}`))
if err != nil {
t.Fatal(err)
}

nAppSpec, err := nAppImage.AppSpec()
if err != nil {
errors.Print(os.Stderr, err, nil)
t.Fatal(err)
}

require.Equal(t, &v1.UserContext{UID: 1000, GID: 1000}, nAppSpec.Containers["foo"].UserContext)

// Expect error because of non-integer values
_, err = NewAppDefinition([]byte(`containers: foo: {
image: "foo:latest"
user: "me:you"
}`))
require.Error(t, err)

// Expect error because of third entry
_, err = NewAppDefinition([]byte(`containers: foo: {
image: "foo:latest"
user: "1000:1000:1000"
}`))
require.Error(t, err)
}
7 changes: 7 additions & 0 deletions pkg/controller/appdefinition/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,13 @@ func toContainer(app *v1.AppInstance, tag name.Reference, containerName string,
Resources: app.Status.Scheduling[containerName].Requirements,
}

if container.UserContext != nil {
containerObject.SecurityContext = &corev1.SecurityContext{
RunAsUser: z.Pointer(container.UserContext.UID),
RunAsGroup: z.Pointer(container.UserContext.GID),
}
}

if addWait {
// If a container exposes a port, then add a pre-stop lifecycle hook that sleeps for 5 seconds. This should allow the
// endpoints controller to remove the pods IP on termination and stop sending traffic to the container.
Expand Down
Loading

0 comments on commit a822ff9

Please sign in to comment.