Files
summercms/internal/build/manifest.go
Jakub Zych 2382040b25 feat(19.3-03): summer.yaml build.tags and the summercms_s3 tag that links the S3 uploads driver
- Manifest.Tags from build.tags (^[a-z0-9_]+$, duplicates dropped), round-tripped by SaveManifest
- summer build passes -tags=<list> through goBuildArgs and prints the build tags line
- attach: s3blob imported only by s3_on.go (//go:build summercms_s3); an untagged binary
  refuses an s3:// bucket_url naming the tag, build.tags and -tags
- go mod tidy adds the AWS SDK v2 modules as indirect requirements
- storage, modules, scaffolding, configuration and scaling docs plus the lagoon README
2026-10-15 18:07:05 +02:00

297 lines
7.7 KiB
Go

package build
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"unicode"
"github.com/knadh/koanf/parsers/yaml"
)
const manifestName = "summer.yaml"
var (
pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
buildTagRe = regexp.MustCompile(`^[a-z0-9_]+$`)
)
// Manifest is the ordered app plugin list used to generate imports, plus
// the Go build tags summer build passes to go build (summer.yaml build.tags).
type Manifest struct {
Module string
Binary string
Plugins []Plugin
Tags []string
}
// Plugin is one compiled plugin module in manifest order.
type Plugin struct {
ID string
Module string
}
// LoadManifest reads and validates summer.yaml at path.
func LoadManifest(path string) (Manifest, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Manifest{}, fmt.Errorf("build: read %s: %w", path, err)
}
tree, err := yaml.Parser().Unmarshal(raw)
if err != nil {
return Manifest{}, fmt.Errorf("build: parse %s: %w", path, err)
}
m := Manifest{
Module: strings.TrimSpace(asString(tree["module"])),
Binary: strings.TrimSpace(asString(tree["binary"])),
}
if err := validateModulePath(m.Module); err != nil {
return Manifest{}, fmt.Errorf("build: module: %w", err)
}
if err := validateBinary(m.Binary); err != nil {
return Manifest{}, fmt.Errorf("build: binary: %w", err)
}
seenID := make(map[string]struct{})
seenMod := make(map[string]struct{})
for i, item := range asSlice(tree["plugins"]) {
entry := asMap(item)
if entry == nil {
return Manifest{}, fmt.Errorf("build: plugins[%d] must be a mapping with id and module", i)
}
p := Plugin{
ID: strings.TrimSpace(asString(entry["id"])),
Module: strings.TrimSpace(asString(entry["module"])),
}
if err := ValidatePluginID(p.ID); err != nil {
return Manifest{}, fmt.Errorf("build: plugins[%d]: %w", i, err)
}
if _, dup := seenID[p.ID]; dup {
return Manifest{}, fmt.Errorf("build: duplicate plugin id %q", p.ID)
}
if err := validateModulePath(p.Module); err != nil {
return Manifest{}, fmt.Errorf("build: plugins[%d] module: %w", i, err)
}
if _, dup := seenMod[p.Module]; dup {
return Manifest{}, fmt.Errorf("build: duplicate plugin module %q", p.Module)
}
seenID[p.ID] = struct{}{}
seenMod[p.Module] = struct{}{}
m.Plugins = append(m.Plugins, p)
}
tags, err := loadBuildTags(tree["build"])
if err != nil {
return Manifest{}, err
}
m.Tags = tags
return m, nil
}
// loadBuildTags reads the optional build mapping and its tags list. Entries
// are trimmed and validated; duplicates are dropped keeping the first.
func loadBuildTags(raw any) ([]string, error) {
if raw == nil {
return nil, nil
}
section := asMap(raw)
if section == nil {
return nil, fmt.Errorf("build: build must be a mapping (build.tags)")
}
rawTags, ok := section["tags"]
if !ok || rawTags == nil {
return nil, nil
}
list, ok := rawTags.([]any)
if !ok {
return nil, fmt.Errorf("build: build.tags must be a list of tags")
}
var tags []string
seen := make(map[string]struct{}, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("build: build.tags[%d] must be a string", i)
}
tag := strings.TrimSpace(s)
if err := ValidateBuildTag(tag); err != nil {
return nil, fmt.Errorf("build: build.tags[%d]: %w", i, err)
}
if _, dup := seen[tag]; dup {
continue
}
seen[tag] = struct{}{}
tags = append(tags, tag)
}
return tags, nil
}
// ValidateBuildTag requires a Go build tag of lowercase letters, digits and
// underscores, so a summer.yaml value can never smuggle a flag into go build.
func ValidateBuildTag(tag string) error {
if tag == "" {
return fmt.Errorf("build tag is empty")
}
if !buildTagRe.MatchString(tag) {
return fmt.Errorf("build tag %q must match ^[a-z0-9_]+$", tag)
}
return nil
}
// SaveManifest writes summer.yaml with plugins in slice order and, when
// present, the build.tags list.
func SaveManifest(path string, m Manifest) error {
if err := validateModulePath(m.Module); err != nil {
return fmt.Errorf("build: module: %w", err)
}
if err := validateBinary(m.Binary); err != nil {
return fmt.Errorf("build: binary: %w", err)
}
var b strings.Builder
fmt.Fprintf(&b, "module: %s\n", m.Module)
fmt.Fprintf(&b, "binary: %s\n", m.Binary)
b.WriteString("plugins:\n")
for i, p := range m.Plugins {
if err := ValidatePluginID(p.ID); err != nil {
return fmt.Errorf("build: plugins[%d]: %w", i, err)
}
if err := validateModulePath(p.Module); err != nil {
return fmt.Errorf("build: plugins[%d] module: %w", i, err)
}
fmt.Fprintf(&b, " - id: %s\n", p.ID)
fmt.Fprintf(&b, " module: %s\n", p.Module)
}
if len(m.Tags) > 0 {
b.WriteString("build:\n")
b.WriteString(" tags:\n")
for i, tag := range m.Tags {
if err := ValidateBuildTag(tag); err != nil {
return fmt.Errorf("build: build.tags[%d]: %w", i, err)
}
fmt.Fprintf(&b, " - %s\n", tag)
}
}
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
// ValidatePluginID requires a lowercase vendor.plugin identifier.
func ValidatePluginID(id string) error {
if id == "" {
return fmt.Errorf("plugin id is empty")
}
if strings.ContainsAny(id, " \t\r\n\"'`/\\") {
return fmt.Errorf("plugin id %q contains invalid characters", id)
}
if !pluginIDRe.MatchString(id) {
return fmt.Errorf("plugin id %q must be lowercase vendor.plugin", id)
}
return nil
}
// FindAppDir walks up from start looking for summer.yaml.
func FindAppDir(start string) (string, error) {
return findAppDir(start)
}
func findAppDir(start string) (string, error) {
if start == "" {
return "", fmt.Errorf("build: app directory is empty")
}
dir, err := filepath.Abs(start)
if err != nil {
return "", fmt.Errorf("build: resolve app directory: %w", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, manifestName)); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("build: no %s found from %s", manifestName, start)
}
dir = parent
}
}
func validateBinary(name string) error {
if name == "" {
return fmt.Errorf("is empty")
}
if name != filepath.Base(name) || strings.ContainsRune(name, os.PathSeparator) {
return fmt.Errorf("%q must be a bare file name", name)
}
if name == "." || name == ".." {
return fmt.Errorf("%q is not a valid binary name", name)
}
for _, r := range name {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-') {
return fmt.Errorf("%q contains invalid characters", name)
}
}
return nil
}
func validateModulePath(path string) error {
if path == "" {
return fmt.Errorf("is empty")
}
if strings.HasPrefix(path, "-") {
return fmt.Errorf("%q must not start with -", path)
}
if strings.ContainsAny(path, " \t\r\n\\\"'`$;&|*?<>()[]{}") {
return fmt.Errorf("%q contains invalid characters", path)
}
if filepath.IsAbs(path) || strings.Contains(path, `\`) {
return fmt.Errorf("%q is not a valid module path", path)
}
for _, seg := range strings.Split(path, "/") {
if seg == "" || seg == "." || seg == ".." {
return fmt.Errorf("%q has an invalid path segment", path)
}
if strings.Contains(seg, "@") {
return fmt.Errorf("%q must not contain a version suffix", path)
}
}
return nil
}
func asString(v any) string {
s, _ := v.(string)
return s
}
func asSlice(v any) []any {
switch s := v.(type) {
case []any:
return s
case nil:
return nil
default:
return nil
}
}
func asMap(v any) map[string]any {
switch m := v.(type) {
case map[string]any:
return m
case map[any]any:
out := make(map[string]any, len(m))
for k, val := range m {
ks, ok := k.(string)
if !ok {
continue
}
out[ks] = val
}
return out
default:
return nil
}
}