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
36 changes: 31 additions & 5 deletions internal/lint/config_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package lint

import (
"fmt"
"strings"

"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -32,10 +33,6 @@ type Config struct {

Relationships map[string]any `yaml:"relationships,omitempty"`
Mounts map[string]Mount `yaml:"mounts,omitempty"`

Egress struct {
Build any `yaml:"build,omitempty"`
} `yaml:"egress,omitempty"`
} `yaml:"web,omitempty"`

Relationships map[string]any `yaml:"relationships,omitempty"`
Expand Down Expand Up @@ -104,6 +101,7 @@ type Task struct {
Authorizations []Authorization `yaml:"authorizations,omitempty"`

// fields lists the keys set on the task, to check that "base" is used alone.
// As on the platform, this includes keys starting with ".".
fields []string
}

Expand Down Expand Up @@ -139,13 +137,41 @@ type Authorization struct {
}

func DecodeConfig(content string) (*Config, error) {
var doc yaml.Node
if err := yaml.Unmarshal([]byte(content), &doc); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
var c Config
if err := yaml.Unmarshal([]byte(content), &c); err != nil {
if len(doc.Content) == 0 {
return &c, nil
}
root := resolveAlias(doc.Content[0])
for _, top := range mappingEntries(root) {
if top.key.Value != keyTasks {
dropDotKeys(resolveAlias(top.value))
}
}
if err := root.Decode(&c); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
return &c, nil
}

// dropDotKeys removes the entries of a mapping node whose keys start with ".",
// which the platform ignores (e.g. YAML anchors).
func dropDotKeys(node *yaml.Node) {
if node == nil || node.Kind != yaml.MappingNode {
return
}
content := node.Content[:0]
for i := 0; i+1 < len(node.Content); i += 2 {
if !strings.HasPrefix(node.Content[i].Value, ".") {
content = append(content, node.Content[i], node.Content[i+1])
}
}
node.Content = content
}

// isStackEmpty reports whether a stack declares no runtimes or packages. The
// stack is a mapping of "runtimes" and "packages", each a name, a mapping or a
// list; an empty list or null is also accepted.
Expand Down
225 changes: 223 additions & 2 deletions internal/lint/linter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,8 +526,229 @@ applications:
egress:
build: {allowed_domains: [{host: repo.packagist.org}]}
`,
wantErrors: `linter warnings:
- applications.app.web.egress.build: build-phase egress has no effect on web: set it on the application`,
wantErrors: `linter errors:
- applications.app.web.egress: Additional property build is not allowed`,
},
{
name: "invalid container profile override",
content: `
applications:
app:
type: php:8.4
web:
container_profile: 5
`,
wantErrors: `linter errors:
- applications.app.web.container_profile: Invalid type. Expected: [string,null], given: integer`,
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
result, err := CheckContent(c.content)
require.NoError(t, err)
if c.wantErrors == "" {
assert.False(t, result.HasErrors(), "unexpected errors: %s", result)
assert.False(t, result.HasWarnings(), "unexpected warnings: %s", result)
return
}
assert.Equal(t, c.wantErrors, result.Error())
})
}
}

// Configs that the platform accepts with the Upsun settings.
func TestLint_PlatformLenience(t *testing.T) {
cases := []struct {
name string
content string
wantErrors string
}{
{
name: "null optional fields",
content: `
applications:
app:
type: php:8.4
timezone: null
hooks: {build: null, deploy: null, post_deploy: null}
runtime: {extensions: null, sizing_hints: {request_memory: null}, xdebug: null}
source: {root: null}
mounts:
files: {source: storage, service: null}
web:
size: null
mounts: null
relationships: null
operations: null
commands: {start: null}
upstream: {protocol: null}
locations:
/:
root: null
passthru: null
index: null
request_buffering: {max_request_size: null}
rules:
'\.css$': {expires: null, allow: null}
workers:
queue:
size: null
egress: null
commands: {start: php worker.php, pre_start: null}
crons:
report:
spec: "0 * * * *"
shutdown_timeout: null
commands: {start: php report.php, stop: null}
operations:
op:
timeout: null
commands: {start: echo, stop: null}
egress:
runtime:
allowed_domains:
- host: api.example.com
port: null
routes:
"https://{default}/":
type: upstream
upstream: app:http
id: null
primary: null
tls:
strict_transport_security: {enabled: null, preload: null}
tasks:
agent:
type: python:3.14
timezone: null
egress: null
run: {command: ./run}
`,
},
{
name: "null where the platform rejects it",
content: `
applications:
app:
type: php:8.4
mounts: null
web:
locations:
/: {allow: null}
`,
wantErrors: `linter errors:
- applications.app.mounts: Invalid type. Expected: object, given: null
- applications.app.web.locations./.allow: Invalid type. Expected: boolean, given: null`,
},
{
name: "keys starting with a dot",
content: `
.defaults: &defaults
type: php:8.4
applications:
.base: &base
type: php:8.4
web:
locations:
/: {root: public}
app:
<<: *base
.note: x
relationships: {db: "db:mysql"}
web:
.note: x
commands: {.note: x, start: php -S 0:8080}
locations:
/:
.note: x
root: public
request_buffering: {.note: x}
rules:
'\.css$': {.note: x, allow: true}
mounts:
files: {.note: x, source: storage}
crons:
report:
.note: x
spec: "0 * * * *"
commands: {.note: x, start: php report.php}
workers:
queue:
.note: {anchor: true}
commands: {start: php worker.php}
egress:
.note: x
runtime:
.note: x
allowed_domains: [{host: api.example.com, .note: x}]
services:
.note: x
db:
.note: x
type: mariadb:11.4
routes:
.note: x
"https://{default}/":
.note: x
type: upstream
upstream: app:http
cache: {.note: x, enabled: false}
`,
},
{
name: "keys starting with a dot where the platform reads them",
content: `
applications:
app:
type: php:8.4
crons:
.note: x
tasks:
.note: x
`,
wantErrors: `linter errors:
- applications.app.crons..note: Invalid type. Expected: object, given: string
- tasks..note: Invalid type. Expected: object, given: string`,
},
{
name: "integer max_request_size and dynamic_compression",
content: `
applications:
app:
type: php:8.4
web:
locations:
/:
root: public
dynamic_compression: true
request_buffering: {enabled: true, max_request_size: 5242880}
rules:
'\.css$': {dynamic_compression: false}
`,
},
{
name: "no applications",
content: `
routes:
"https://{default}/":
type: redirect
to: https://example.com/
`,
},
{
name: "custom container profile",
content: `
applications:
app:
type: php:8.4
container_profile: CUSTOM
relationships: {db: "db:mysql"}
services:
db:
type: mariadb:11.4
container_profile: CUSTOM
`,
},
}

Expand Down
3 changes: 3 additions & 0 deletions internal/lint/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ func mergeConfigFiles(fsys fs.FS, files []string) (merged string, sources source
for _, entry := range mappingEntries(section) {
nameNode, valueNode := entry.key, entry.value
name := nameNode.Value
if strings.HasPrefix(name, ".") && key != keyTasks {
continue
}
if prev, exists := sources[key+"."+name]; exists {
return "", nil, &sourceError{file: file, line: nameNode.Line, msg: fmt.Sprintf(
"duplicate key '%s' in section '%s' (already defined in %s)", name, key, prev.file)}
Expand Down
16 changes: 16 additions & 0 deletions internal/lint/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ func TestMergeConfigFiles_DuplicateKey(t *testing.T) {
require.Contains(t, err.Error(), "b.yml:2: duplicate key 'foo' in section 'applications' (already defined in a.yaml)")
}

func TestMergeConfigFiles_DotPrefixedNames(t *testing.T) {
fsys := fstest.MapFS{
"a.yaml": &fstest.MapFile{Data: []byte(`applications:
.base: &base {type: go}
foo: *base`)},
"b.yml": &fstest.MapFile{Data: []byte(`applications:
.base: {type: node}
routes:
.note: x`)},
}
merged, sources, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"})
require.NoError(t, err)
assert.Equal(t, "applications:\n foo:\n type: go\n", merged)
assert.Len(t, sources, 1)
}

func TestGetMergedConfigFiles_Success(t *testing.T) {
fsys := fstest.MapFS{
".upsun/a.yaml": &fstest.MapFile{Data: []byte(`applications:
Expand Down
29 changes: 29 additions & 0 deletions internal/lint/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,32 @@ func TestSchemaDefaults(t *testing.T) {
}
walk(root, "")
}

// TestSchemaKeywords checks that no keyword is beside a $ref, and that mappings
// with fixed keys allow keys starting with ".", which the platform ignores (e.g.
// YAML anchors), except in the stack, which the platform checks itself.
func TestSchemaKeywords(t *testing.T) {
var root map[string]any
require.NoError(t, json.Unmarshal(configSchema, &root))

var walk func(node any, path string)
walk = func(node any, path string) {
switch n := node.(type) {
case map[string]any:
if _, ok := n["$ref"]; ok {
assert.Len(t, n, 1, "%s: draft-07 ignores keywords beside $ref", path)
}
if n["additionalProperties"] == false && path != ".definitions.stack" {
assert.Equal(t, map[string]any{"^\\.": map[string]any{}}, n["patternProperties"], path)
}
for k, v := range n {
walk(v, path+"."+k)
}
case []any:
for i, v := range n {
walk(v, fmt.Sprintf("%s.%d", path, i))
}
}
}
walk(root, "")
}
Loading
Loading