From ad3962695a81bc9e266fcfb11665561bf3fb7daa Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 18 Jun 2026 00:27:54 +0300 Subject: [PATCH 1/2] cli/manifest: add manifest and lock data model Introduce cli/manifest, the data layer for tt packages: Go types for the manifest (app.manifest.toml) and lock (app.manifest.lock), TOML read/write, structural validation, format-version handling and manifest_hash. No git, no dependency resolution, no .rocks/ - only the standard library and the TOML library. * Manifest/Package/Platform with the platform constraint parsed into semver part and [ce]/[ee] flavor (Constraint via TextMarshaler). * Dependency decodes both the short (bare constraint string) and long (table) forms from one map via go-toml's unstable.Unmarshaler. * Lock serializes to the [lock.products.] table tree through an internal wire struct (go-toml does not split dotted struct tags). * Validate() does structural checks; format-version rules split unknown fields (warn on newer minor, fail otherwise, refuse newer major) from unknown enum values (always fail). manifest_hash is SHA-256 of the raw file bytes. Closes TNTP-8220 --- .github/actions/static-code-check/action.yml | 4 +- .golangci.yml | 100 +++++ .pre-commit-config.yaml | 15 + cli/manifest/component.go | 80 ++++ cli/manifest/constraint.go | 98 +++++ cli/manifest/dependency.go | 97 +++++ cli/manifest/errors.go | 41 +++ cli/manifest/lock.go | 141 +++++++ cli/manifest/lock_test.go | 84 +++++ cli/manifest/manifest.go | 192 ++++++++++ cli/manifest/manifest_test.go | 366 +++++++++++++++++++ cli/manifest/testdata/my-app.toml | 36 ++ cli/manifest/validate.go | 329 +++++++++++++++++ cli/manifest/version.go | 90 +++++ go.mod | 1 + 15 files changed, 1673 insertions(+), 1 deletion(-) create mode 100644 .golangci.yml create mode 100644 cli/manifest/component.go create mode 100644 cli/manifest/constraint.go create mode 100644 cli/manifest/dependency.go create mode 100644 cli/manifest/errors.go create mode 100644 cli/manifest/lock.go create mode 100644 cli/manifest/lock_test.go create mode 100644 cli/manifest/manifest.go create mode 100644 cli/manifest/manifest_test.go create mode 100644 cli/manifest/testdata/my-app.toml create mode 100644 cli/manifest/validate.go create mode 100644 cli/manifest/version.go diff --git a/.github/actions/static-code-check/action.yml b/.github/actions/static-code-check/action.yml index 48aaee1cd..e47f429ed 100644 --- a/.github/actions/static-code-check/action.yml +++ b/.github/actions/static-code-check/action.yml @@ -35,7 +35,9 @@ runs: - name: pre-commit checks (diff) uses: pre-commit/action@v3.0.1 env: - SKIP: golangci-lint-full + # golangci-lint-strict scans whole packages (not diff-based), so run it + # once in the "full" step below instead of here. + SKIP: golangci-lint-full,golangci-lint-strict with: extra_args: --all-files --from-ref=${{ env.BASE_BRANCH }} --to-ref=HEAD --hook-stage=manual diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..9c69d28fd --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,100 @@ +version: "2" + +# NOTE: this config is intentionally scoped to the freshly written code only. +# The `cli/manifest` exclusion below limits all linters to that package so the +# strict ruleset can be adopted incrementally instead of flooding the whole tt +# tree with findings. Drop the `path-except` rule to lint everything. +# +# The project-wide lint still runs from `golangci-lint.yml` (see magefile.go); +# this `.golangci.yml` is what a bare `golangci-lint run` picks up. + +run: + timeout: 3m + build-tags: + - go_tarantool_ssl_disable + - tt_ssl_disable + +formatters: + enable: + - goimports + +issues: + # Disable limits on the number of printed issues + max-issues-per-linter: 0 # 0 = no limit + max-same-issues: 0 # 0 = no limit + +linters: + default: all + + disable: + - dupl # Dupl is disabled, since we're generating a lot of boilerplate code. + - cyclop # Cyclop is disabled, since cyclomatic complexities is very abstract metric, + # that sometimes lead to strange linter behaviour. + - wsl # WSL is disabled, since it's obsolete. Using WSL_v5. + - nlreturn # nlreturn is disabled, since it's duplicated by wsl_v5.return check. + - ireturn # ireturn is disabled, since it's not needed. + - godox # godox is disabled to allow TODO comments for unimplemented functionality. + - gocognit # gocognit is disabled, cognitive complexity is too restrictive. + - funlen # funlen is disabled, function length limits are too restrictive. + - maintidx # maintidx is disabled, purpose of this metric is unknown. + + exclusions: + generated: lax + rules: + # Scope linting to the freshly written code only. An issue is excluded + # when its path is NOT under cli/manifest/. + - path-except: (^|/)cli/manifest/ + text: ".*" + - path: _test.go + linters: + - wrapcheck + - err113 + - funlen + + settings: + varnamelen: + ignore-names: + - tt + - ok + - tb + ignore-decls: + - t T + revive: + rules: + - name: var-naming + arguments: + - [] + - [] + - - skip-package-name-checks: true + + exhaustive: + # A switch with a default clause is treated as exhaustive. + default-signifies-exhaustive: true + godot: + scope: all + lll: + line-length: 100 + tab-width: 4 + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 + case-max-lines: 0 + default: all + depguard: + rules: + main: + files: + - "$all" + - "!$test" + allow: + - $gostd + - "github.com/pelletier/go-toml/v2" + test: + files: + - "$test" + allow: + - $gostd + - "github.com/pelletier/go-toml/v2" + - "github.com/stretchr/testify" + - "github.com/tarantool/tt/cli/manifest" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca8e22260..8bdb348b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -115,6 +115,21 @@ repos: additional_dependencies: - github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 + - id: golangci-lint-strict + language: golang + types: [go] + name: "Go: strict lint (migrated packages)" + description: | + Strict `default: all` ruleset, scoped by `.golangci.yml` to packages + that have been fully cleaned up (currently `cli/manifest`). Adopt it + for more code by widening the `path-except` rule in `.golangci.yml`. + pass_filenames: false + always_run: true + stages: [manual] + entry: golangci-lint run --config=.golangci.yml --allow-parallel-runners + additional_dependencies: + - github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 + - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.2 hooks: diff --git a/cli/manifest/component.go b/cli/manifest/component.go new file mode 100644 index 000000000..7a10a47b2 --- /dev/null +++ b/cli/manifest/component.go @@ -0,0 +1,80 @@ +package manifest + +// Build backends. +const ( + backendMake = "make" + backendShell = "shell" + backendC = "c" + backendLuaC = "lua-c" +) + +// Component is a group of files plus an optional build ([components.]). +type Component struct { + Path string `toml:"path"` // Required. + Include []string `toml:"include,omitempty"` // Default *.lua, *.so. + Exclude []string `toml:"exclude,omitempty"` + Namespace *string `toml:"namespace,omitempty"` // Nil: pkg name; "": flat. + Dependencies map[string]Dependency `toml:"dependencies,omitempty"` + Build *Build `toml:"build,omitempty"` +} + +// Build describes the native build of a component ([components..build]). +// The Hook type is an alias of Build; hooks reuse the same shape but allow only +// the make/shell backends. +type Build struct { + Backend string `toml:"backend"` // make|shell|c|lua-c - closed enum. + Cwd string `toml:"cwd,omitempty"` + Env map[string]string `toml:"env,omitempty"` + Output []string `toml:"output,omitempty"` + + // make backend. + MakeTarget string `toml:"make_target,omitempty"` + Entrypoint string `toml:"entrypoint,omitempty"` + Flags []string `toml:"flags,omitempty"` + + // shell backend. + Command string `toml:"command,omitempty"` + Args []string `toml:"args,omitempty"` + + // c / lua-c backends. + Module string `toml:"module,omitempty"` + Sources []string `toml:"sources,omitempty"` + IncludeDirs []string `toml:"include_dirs,omitempty"` + Libraries []string `toml:"libraries,omitempty"` + LibraryDirs []string `toml:"library_dirs,omitempty"` + Defines []string `toml:"defines,omitempty"` + Platforms map[string]BuildOverlay `toml:"platforms,omitempty"` +} + +// BuildOverlay is the per-OS addition to a c/lua-c build +// ([components..build.platforms.]). It only adds to the base lists +// for the current OS. +type BuildOverlay struct { + IncludeDirs []string `toml:"include_dirs,omitempty"` + Libraries []string `toml:"libraries,omitempty"` + LibraryDirs []string `toml:"library_dirs,omitempty"` + Defines []string `toml:"defines,omitempty"` +} + +// Product is a named set of components built and packed as a unit +// ([products.]). +type Product struct { + Components []string `toml:"components"` // Required; names must exist in [components]. + Default bool `toml:"default,omitempty"` +} + +// Hook is a lifecycle hook ([hooks.pre_build]/[hooks.post_build]). It shares +// the Build shape, but only the make/shell backends are valid and there is no +// module/sources. +type Hook = Build + +// EffectiveNamespace returns the install namespace of the component given the +// package name. An unset namespace falls back to the package name; an explicit +// empty string means a flat layout. +func (c Component) EffectiveNamespace(packageName string) string { + if c.Namespace == nil { + return packageName + } + + return *c.Namespace +} diff --git a/cli/manifest/constraint.go b/cli/manifest/constraint.go new file mode 100644 index 000000000..f0e21b4bb --- /dev/null +++ b/cli/manifest/constraint.go @@ -0,0 +1,98 @@ +package manifest + +import "strings" + +// Constraint is a platform version requirement parsed into its semver part and +// an optional flavor. It is stored as "[]" in TOML, +// where is [ce] or [ee]. +// +// The semver constraint itself is kept verbatim - validating the range is the +// resolver's job, not this layer's. Only the flavor suffix is parsed here. +// +//nolint:recvcheck // TextMarshaler needs a value receiver, UnmarshalText a pointer. +type Constraint struct { + // Version is the semver constraint part, e.g. ">=3.0.0,<4.0.0". Empty for + // an unset (omitted) constraint. + Version string + // Flavor is "ce", "ee" or "" (unspecified). For tarantool/tt an empty + // flavor means [ce]; see EffectiveFlavor. + Flavor string +} + +// IsZero reports whether the constraint is unset. +func (c Constraint) IsZero() bool { + return c.Version == "" && c.Flavor == "" +} + +// EffectiveFlavor returns the flavor with the [ce] default applied to an +// unspecified flavor. +func (c Constraint) EffectiveFlavor() string { + if c.Flavor == "" { + return "ce" + } + + return c.Flavor +} + +// String renders the constraint back to its "[]" form. +func (c Constraint) String() string { + if c.Flavor == "" { + return c.Version + } + + return c.Version + "[" + c.Flavor + "]" +} + +// MarshalText implements encoding.TextMarshaler so the constraint serializes as +// a TOML string. +func (c Constraint) MarshalText() ([]byte, error) { + return []byte(c.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler. It splits off a trailing +// [flavor] suffix and validates the flavor token; an unknown flavor or a +// bracket suffix with no version is a parse error. The semver part is stored +// verbatim. +func (c *Constraint) UnmarshalText(text []byte) error { + version, flavor, err := splitFlavor(string(text)) + if err != nil { + return err + } + + c.Version = version + c.Flavor = flavor + + return nil +} + +// splitFlavor separates a "[]" string into its parts. A string +// with no trailing [flavor] suffix is returned verbatim as the version with an +// empty flavor. An unbalanced bracket, an unknown flavor token, or a suffix +// with no version before it is a parse error. +func splitFlavor(raw string) (string, string, error) { + if !strings.HasSuffix(raw, "]") { + return raw, "", nil + } + + open := strings.LastIndex(raw, "[") + if open < 0 { + return "", "", invalid("", "invalid version constraint %q: unbalanced %q", raw, "[") + } + + version := raw[:open] + flavor := raw[open+1 : len(raw)-1] + + switch flavor { + case "ce", "ee": + default: + return "", "", invalid("", + "invalid version constraint %q: unknown flavor %q (want [ce] or [ee])", raw, flavor) + } + + if version == "" { + return "", "", invalid("", + "invalid version constraint %q: missing version before flavor", raw) + } + + return version, flavor, nil +} diff --git a/cli/manifest/dependency.go b/cli/manifest/dependency.go new file mode 100644 index 000000000..3058c139b --- /dev/null +++ b/cli/manifest/dependency.go @@ -0,0 +1,97 @@ +package manifest + +import "github.com/pelletier/go-toml/v2/unstable" + +// Source values for a dependency. +const ( + sourceRegistry = "registry" + sourcePath = "path" +) + +// Dependency is one entry of a [dependencies]/[dev_dependencies] map or a +// component's [components..dependencies] map. +// +// It is written in two forms. The short form is a bare constraint string +// (luasocket = ">=3.0.0,<4.0.0"), where source defaults to "registry". The +// long form is a table with source/version/path/registry. Both forms occur in +// the same map, so the dual-form decoding lives in UnmarshalTOML. +type Dependency struct { + Source string `toml:"source,omitempty"` // Source: "registry" (default) or "path". + Version string `toml:"version,omitempty"` // Constraint; required for registry. + Path string `toml:"path,omitempty"` // Required for path. + Registry string `toml:"registry,omitempty"` // Overrides the server. + Kind string `toml:"kind,omitempty"` // In v0 only "library". +} + +// UnmarshalTOML implements the go-toml unstable.Unmarshaler interface so a +// dependency can decode from either a bare string or a table. The decoder must +// have EnableUnmarshalerInterface set (ParseManifest/ParseLock do). +func (d *Dependency) UnmarshalTOML(node *unstable.Node) error { + switch node.Kind { + case unstable.String: + // Short form: a bare constraint string. + d.Source = sourceRegistry + d.Version = string(node.Data) + + return nil + case unstable.Table, unstable.InlineTable: + return d.unmarshalTable(node) + default: + return invalid("", "dependency must be a constraint string or a table, got %s", node.Kind) + } +} + +// unmarshalTable decodes the long form: a table with source/version/path/ +// registry/kind fields. An empty source defaults to "registry". +func (d *Dependency) unmarshalTable(node *unstable.Node) error { + it := node.Children() + for it.Next() { + entry := it.Node() + + keyIter := entry.Key() + if !keyIter.Next() { + continue + } + + key := string(keyIter.Node().Data) + + value := entry.Value() + switch value.Kind { + case unstable.String, unstable.Integer, unstable.Float, unstable.Bool: + default: + return invalid("", "dependency field %q must be a scalar value", key) + } + + err := d.setField(key, string(value.Data)) + if err != nil { + return err + } + } + + if d.Source == "" { + d.Source = sourceRegistry + } + + return nil +} + +// setField assigns one decoded long-form field by its TOML key, rejecting +// unknown keys. +func (d *Dependency) setField(key, val string) error { + switch key { + case "source": + d.Source = val + case "version": + d.Version = val + case "path": + d.Path = val + case "registry": + d.Registry = val + case "kind": + d.Kind = val + default: + return invalid("", "unknown dependency field %q", key) + } + + return nil +} diff --git a/cli/manifest/errors.go b/cli/manifest/errors.go new file mode 100644 index 000000000..38930cd24 --- /dev/null +++ b/cli/manifest/errors.go @@ -0,0 +1,41 @@ +package manifest + +import ( + "errors" + "fmt" +) + +// ErrUnsupportedVersion is returned when a manifest_version or lock_version +// declares a major component this build of tt does not support. It is wrapped +// with the concrete versions for context; callers match it with errors.Is and +// typically respond by asking the user to upgrade tt. +var ErrUnsupportedVersion = errors.New("not supported by this tt") + +// ValidationError is a structural problem with a manifest or lock, tied to a +// specific field. Field is a dotted path ("package.name", +// "components.api.build.make_target", "dependencies.luasocket"); it is empty +// for errors found while decoding, where the TOML layer supplies the location. +// +// Callers match it with errors.As to locate the offending field rather than +// scraping the message text. +type ValidationError struct { + Field string + Msg string +} + +// Error renders the error as ": ", or just the message when no +// field is attached. +func (e *ValidationError) Error() string { + if e.Field == "" { + return e.Msg + } + + return e.Field + ": " + e.Msg +} + +// invalid builds a *ValidationError with a printf-style message. The returned +// error is the interface type so callers can return it directly without +// tripping the typed-nil trap. +func invalid(field, format string, args ...any) error { + return &ValidationError{Field: field, Msg: fmt.Sprintf(format, args...)} +} diff --git a/cli/manifest/lock.go b/cli/manifest/lock.go new file mode 100644 index 000000000..53ca479d9 --- /dev/null +++ b/cli/manifest/lock.go @@ -0,0 +1,141 @@ +package manifest + +import ( + "bytes" + "fmt" + + "github.com/pelletier/go-toml/v2" +) + +// LockVersion is the lock format version this build of tt writes. +const LockVersion = "0.1" + +// Lock is the parsed form of app.manifest.lock. It records the exact versions +// and hashes tt resolved, one snapshot per product. The dependency fields are +// filled by the resolver; this layer only owns the shape and serialization. +type Lock struct { + LockVersion string `toml:"lock_version"` + ManifestVersion string `toml:"manifest_version"` + GeneratedBy string `toml:"generated_by"` // Like "tt 3.x.y". + ManifestHash string `toml:"manifest_hash"` // Like "sha256:...". + BundledTarantool string `toml:"bundled_tarantool_version,omitempty"` + BundledTt string `toml:"bundled_tt_version,omitempty"` + BundledTcm string `toml:"bundled_tcm_version,omitempty"` + + // Products maps product name to its resolution snapshot. On disk this is + // the [lock.products.] table tree. + Products map[string]LockProduct `toml:"-"` +} + +// LockProduct is one product's resolution snapshot - the full transitive +// closure resolved for that product. +type LockProduct struct { + Dependencies []LockDependency `toml:"dependencies"` +} + +// LockDependency is one resolved dependency inside a product snapshot +// ([[lock.products..dependencies]]). +type LockDependency struct { + Name string `toml:"name"` + Version string `toml:"version"` // Exact version. + Source string `toml:"source"` // Registry or path. + Checksum string `toml:"checksum,omitempty"` // Registry: "md5:..." or "sha256:...". + Path string `toml:"path,omitempty"` // Path source. + ContentHash string `toml:"content_hash,omitempty"` // Path: sha256 of contents. +} + +// lockWire is the on-disk shape of the lock. It nests Products under a [lock] +// table so the file reads [lock.products.]; go-toml does not split dotted +// struct tags, so the nesting is expressed with a real struct. +type lockWire struct { + LockVersion string `toml:"lock_version"` + ManifestVersion string `toml:"manifest_version"` + GeneratedBy string `toml:"generated_by"` + ManifestHash string `toml:"manifest_hash"` + BundledTarantool string `toml:"bundled_tarantool_version,omitempty"` + BundledTt string `toml:"bundled_tt_version,omitempty"` + BundledTcm string `toml:"bundled_tcm_version,omitempty"` + Lock struct { + Products map[string]LockProduct `toml:"products,omitempty"` + } `toml:"lock"` +} + +// ParseLock parses app.manifest.lock bytes into a Lock. A newer major +// lock_version is refused; unknown fields are tolerated (the lock is tt-owned +// state, not a hand-authored evolution surface). +func ParseLock(data []byte) (*Lock, error) { + var head struct { + LockVersion string `toml:"lock_version"` + } + + err := toml.Unmarshal(data, &head) + if err != nil { + return nil, fmt.Errorf("parsing lock: %w", err) + } + + if head.LockVersion == "" { + return nil, invalid("lock_version", "is required") + } + + declared, err := parseFormatVersion(head.LockVersion) + if err != nil { + return nil, err + } + + if declared.major > ourLockVersion.major { + return nil, fmt.Errorf("lock version %q %w (supports %d.x)", + declared, ErrUnsupportedVersion, ourLockVersion.major) + } + + var wire lockWire + + dec := toml.NewDecoder(bytes.NewReader(data)) + dec.EnableUnmarshalerInterface() + + err = dec.Decode(&wire) + if err != nil { + return nil, fmt.Errorf("parsing lock: %w", err) + } + + lock := wire.toLock() + + return &lock, nil +} + +// Marshal serializes the lock to TOML in canonical form. +func (l Lock) Marshal() ([]byte, error) { + data, err := toml.Marshal(l.toWire()) + if err != nil { + return nil, fmt.Errorf("marshaling lock: %w", err) + } + + return data, nil +} + +func (l Lock) toWire() lockWire { + var wire lockWire + + wire.LockVersion = l.LockVersion + wire.ManifestVersion = l.ManifestVersion + wire.GeneratedBy = l.GeneratedBy + wire.ManifestHash = l.ManifestHash + wire.BundledTarantool = l.BundledTarantool + wire.BundledTt = l.BundledTt + wire.BundledTcm = l.BundledTcm + wire.Lock.Products = l.Products + + return wire +} + +func (w lockWire) toLock() Lock { + return Lock{ + LockVersion: w.LockVersion, + ManifestVersion: w.ManifestVersion, + GeneratedBy: w.GeneratedBy, + ManifestHash: w.ManifestHash, + BundledTarantool: w.BundledTarantool, + BundledTt: w.BundledTt, + BundledTcm: w.BundledTcm, + Products: w.Lock.Products, + } +} diff --git a/cli/manifest/lock_test.go b/cli/manifest/lock_test.go new file mode 100644 index 000000000..e5ae0e6ec --- /dev/null +++ b/cli/manifest/lock_test.go @@ -0,0 +1,84 @@ +package manifest_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest" +) + +func TestLockRoundTrip(t *testing.T) { + t.Parallel() + + lock := &manifest.Lock{ + LockVersion: manifest.LockVersion, + ManifestVersion: manifest.ManifestVersion, + GeneratedBy: "tt 3.1.0", + ManifestHash: "sha256:abc123", + BundledTarantool: "3.0.5", + BundledTt: "", + BundledTcm: "", + Products: map[string]manifest.LockProduct{ + "default": {Dependencies: []manifest.LockDependency{ + { + Name: "luasocket", + Version: "3.0.4", + Source: "registry", + Checksum: "md5:deadbeef", + Path: "", + ContentHash: "", + }, + { + Name: "local-helper", + Version: "0.1.0", + Source: "path", + Checksum: "", + Path: "../helper", + ContentHash: "sha256:cafe", + }, + }}, + "minimal": {Dependencies: []manifest.LockDependency{ + { + Name: "luasocket", + Version: "3.0.4", + Source: "registry", + Checksum: "md5:deadbeef", + Path: "", + ContentHash: "", + }, + }}, + }, + } + + out, err := lock.Marshal() + require.NoError(t, err) + + // The on-disk shape uses the [lock.products.] table tree. + text := string(out) + assert.Contains(t, text, "[lock.products.default]") + assert.Contains(t, text, "[[lock.products.default.dependencies]]") + assert.NotContains(t, text, "'lock.products'", "must not be a single quoted key") + + back, err := manifest.ParseLock(out) + require.NoError(t, err) + assert.Equal(t, lock.Products, back.Products) + assert.Equal(t, lock.BundledTarantool, back.BundledTarantool) + assert.Equal(t, "tt 3.1.0", back.GeneratedBy) +} + +func TestLockNewerMajorRefused(t *testing.T) { + t.Parallel() + + _, err := manifest.ParseLock([]byte("lock_version = \"1.0\"\n")) + require.ErrorIs(t, err, manifest.ErrUnsupportedVersion) +} + +func TestLockRequiresVersion(t *testing.T) { + t.Parallel() + + _, err := manifest.ParseLock([]byte(strings.TrimSpace("generated_by = \"tt 3.1.0\""))) + require.Error(t, err) +} diff --git a/cli/manifest/manifest.go b/cli/manifest/manifest.go new file mode 100644 index 000000000..f0159d200 --- /dev/null +++ b/cli/manifest/manifest.go @@ -0,0 +1,192 @@ +// Package manifest is the data layer for tt packages: Go types for the +// manifest (app.manifest.toml) and the lock (app.manifest.lock), TOML reading +// and writing, structural validation and format-version handling. +// +// It does no git, no dependency resolution and never touches .rocks/. Every +// other package of the tt manifest pipeline imports it, so its only +// dependencies are the standard library and the TOML library. +package manifest + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// ManifestVersion is the format version this build of tt writes and natively +// understands. The string is ".". +const ManifestVersion = "0.1" + +// Manifest is the parsed form of app.manifest.toml. +// +// The raw bytes of the source file are kept so manifest_hash can be computed +// without re-reading the file; any change to the file (including comments and +// formatting) changes the hash. +type Manifest struct { + ManifestVersion string `toml:"manifest_version"` + Package Package `toml:"package"` + Platform Platform `toml:"platform"` + Dependencies map[string]Dependency `toml:"dependencies,omitempty"` + DevDependencies map[string]Dependency `toml:"dev_dependencies,omitempty"` + Components map[string]Component `toml:"components,omitempty"` + Products map[string]Product `toml:"products,omitempty"` + Hooks map[string]Hook `toml:"hooks,omitempty"` // Keys: pre_build, post_build. + + raw []byte // Raw file bytes, source for manifest_hash. +} + +// Package holds the package identity and metadata ([package]). +type Package struct { + Name string `toml:"name"` // Regex [a-z][a-z0-9-]*; not "bin"/"manifests". + Description string `toml:"description,omitempty"` + License string `toml:"license,omitempty"` + LicenseFiles []string `toml:"license_files,omitempty"` + Include []string `toml:"include,omitempty"` + Repository string `toml:"repository,omitempty"` + Authors []string `toml:"authors,omitempty"` + GenerateVersionLua *bool `toml:"generate_version_lua,omitempty"` // Nil means true. +} + +// Platform describes the runtime requirements ([platform]). +// +// The version constraints are stored already parsed: the semver part and the +// flavor are kept separately so [ce]/[ee] is not parsed twice down the line. +type Platform struct { + Tarantool Constraint `toml:"tarantool"` // Required; flavor [ce]/[ee], default [ce]. + Tt Constraint `toml:"tt"` // Required. + Tcm Constraint `toml:"tcm,omitempty"` // No flavor - TCM is Enterprise only. + Platforms []string `toml:"platforms,omitempty"` // One of linux/darwin-amd64/arm64 or any. +} + +// GenerateVersionLuaValue reports the effective value of +// package.generate_version_lua, defaulting to true when unset. +func (p Package) GenerateVersionLuaValue() bool { + return p.GenerateVersionLua == nil || *p.GenerateVersionLua +} + +// ParseManifest parses app.manifest.toml bytes into a Manifest. +// +// It applies the format-version rules to unknown fields: an unknown field in a +// manifest of a newer minor version produces a warning and parsing continues; +// in the current or an older minor version it is a hard error; a newer major +// version is refused outright. Unknown values of known enum fields are not +// handled here - they are caught by Validate regardless of format version. +// +// The returned warnings are non-fatal diagnostics for the caller to surface. +func ParseManifest(data []byte) (*Manifest, []string, error) { + declared, err := peekManifestVersion(data) + if err != nil { + return nil, nil, err + } + + // A different major in either direction is refused: a newer major may have + // changed the format, and no older major exists for 0.x to fall back to. + if declared.major != ourManifestVersion.major { + return nil, nil, fmt.Errorf("manifest version %q %w (supports %d.x)", + declared, ErrUnsupportedVersion, ourManifestVersion.major) + } + + out := new(Manifest) + dec := toml.NewDecoder(bytes.NewReader(data)) + dec.EnableUnmarshalerInterface() + dec.DisallowUnknownFields() + + var warnings []string + + decErr := dec.Decode(out) + if decErr != nil { + warnings, err = handleDecodeError(decErr, declared) + if err != nil { + return nil, nil, err + } + } + + out.raw = append([]byte(nil), data...) + + return out, warnings, nil +} + +// handleDecodeError classifies a strict-decode failure. An unknown field is a +// warning when the manifest is a newer minor version and a hard error +// otherwise; any other decode failure is returned wrapped. +func handleDecodeError(decErr error, declared formatVersion) ([]string, error) { + var strict *toml.StrictMissingError + if !errors.As(decErr, &strict) { + return nil, fmt.Errorf("parsing manifest: %w", decErr) + } + + unknown := unknownKeys(strict) + if !declared.minorNewerThan(ourManifestVersion) { + return nil, invalid(unknown[0], "unknown field in manifest version %q", declared) + } + + warnings := make([]string, 0, len(unknown)) + for _, k := range unknown { + warnings = append(warnings, fmt.Sprintf( + "field %q appeared in a newer manifest version; trying to ignore it", k)) + } + + return warnings, nil +} + +// Marshal serializes the manifest back to TOML. Output is canonical go-toml +// form; comments and original formatting are not preserved. +func (m *Manifest) Marshal() ([]byte, error) { + data, err := toml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("marshaling manifest: %w", err) + } + + return data, nil +} + +// Hash returns the manifest_hash: SHA-256 of the raw source bytes, tagged +// "sha256:". It is computed over the bytes ParseManifest was given. +func (m *Manifest) Hash() string { + return HashBytes(m.raw) +} + +// Raw returns the raw source bytes the manifest was parsed from. +func (m *Manifest) Raw() []byte { + return m.raw +} + +// HashBytes returns the "sha256:" hash of arbitrary manifest bytes. +func HashBytes(data []byte) string { + sum := sha256.Sum256(data) + + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// peekManifestVersion reads only the manifest_version field, leniently. +func peekManifestVersion(data []byte) (formatVersion, error) { + var head struct { + ManifestVersion string `toml:"manifest_version"` + } + + err := toml.Unmarshal(data, &head) + if err != nil { + return formatVersion{}, fmt.Errorf("parsing manifest: %w", err) + } + + if head.ManifestVersion == "" { + return formatVersion{}, invalid("manifest_version", "is required") + } + + return parseFormatVersion(head.ManifestVersion) +} + +// unknownKeys flattens StrictMissingError into dotted key paths. +func unknownKeys(strict *toml.StrictMissingError) []string { + keys := make([]string, 0, len(strict.Errors)) + for _, e := range strict.Errors { + keys = append(keys, strings.Join(e.Key(), ".")) + } + + return keys +} diff --git a/cli/manifest/manifest_test.go b/cli/manifest/manifest_test.go new file mode 100644 index 000000000..77e76b078 --- /dev/null +++ b/cli/manifest/manifest_test.go @@ -0,0 +1,366 @@ +package manifest_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest" +) + +// TestRoundTripMyApp parses the canonical my-app example, serializes it back +// and checks the bytes are identical. +func TestRoundTripMyApp(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(filepath.Join("testdata", "my-app.toml")) + require.NoError(t, err) + + mfst, warnings, err := manifest.ParseManifest(data) + require.NoError(t, err) + require.Empty(t, warnings) + + _, err = mfst.Validate() + require.NoError(t, err) + + out, err := mfst.Marshal() + require.NoError(t, err) + require.Equal(t, string(data), string(out), "round-trip must be byte-identical") +} + +// TestDependencyForms checks the short (bare string) and long (table) forms +// decode side by side in one map. +func TestDependencyForms(t *testing.T) { + t.Parallel() + + src := ` +manifest_version = "0.1" +[package] +name = "demo" +[platform] +tarantool = ">=3.0.0" +tt = ">=3.0.0" + +[dependencies] +luasocket = ">=3.0.0,<4.0.0" +inline = { source = "registry", version = ">=1.0.0" } + +[dependencies.local-helper] +source = "path" +path = "../helper" +` + mfst, _, err := manifest.ParseManifest([]byte(src)) + require.NoError(t, err) + + // Short form: bare constraint, source defaults to registry. + assert.Equal(t, manifest.Dependency{ + Source: "registry", Version: ">=3.0.0,<4.0.0", Path: "", Registry: "", Kind: "", + }, mfst.Dependencies["luasocket"]) + // Inline table. + assert.Equal(t, manifest.Dependency{ + Source: "registry", Version: ">=1.0.0", Path: "", Registry: "", Kind: "", + }, mfst.Dependencies["inline"]) + // Long form: path source. + assert.Equal(t, manifest.Dependency{ + Source: "path", Version: "", Path: "../helper", Registry: "", Kind: "", + }, mfst.Dependencies["local-helper"]) + + _, err = mfst.Validate() + require.NoError(t, err) +} + +// TestPlatformFlavors covers the flavor suffix variants and the tcm rule. +func TestPlatformFlavors(t *testing.T) { + t.Parallel() + + parse := func(t *testing.T, platform string) (*manifest.Manifest, error) { + t.Helper() + + src := "manifest_version = \"0.1\"\n[package]\nname = \"demo\"\n[platform]\n" + platform + mfst, _, err := manifest.ParseManifest([]byte(src)) + + return mfst, err + } + + t.Run("ce explicit", func(t *testing.T) { + t.Parallel() + + mfst, err := parse(t, "tarantool = \">=3.0.0[ce]\"\ntt = \">=3.0.0\"\n") + require.NoError(t, err) + assert.Equal(t, "ce", mfst.Platform.Tarantool.Flavor) + assert.Equal(t, ">=3.0.0", mfst.Platform.Tarantool.Version) + }) + + t.Run("ee explicit", func(t *testing.T) { + t.Parallel() + + mfst, err := parse(t, "tarantool = \">=3.0.0[ee]\"\ntt = \">=3.0.0\"\n") + require.NoError(t, err) + assert.Equal(t, "ee", mfst.Platform.Tarantool.Flavor) + }) + + t.Run("no brackets defaults to ce", func(t *testing.T) { + t.Parallel() + + mfst, err := parse(t, "tarantool = \">=3.0.0\"\ntt = \">=3.0.0\"\n") + require.NoError(t, err) + assert.Empty(t, mfst.Platform.Tarantool.Flavor) + assert.Equal(t, "ce", mfst.Platform.Tarantool.EffectiveFlavor()) + }) + + t.Run("unknown flavor is a parse error", func(t *testing.T) { + t.Parallel() + + _, err := parse(t, "tarantool = \">=3.0.0[xx]\"\ntt = \">=3.0.0\"\n") + require.Error(t, err) + }) + + t.Run("flavor without version is a parse error", func(t *testing.T) { + t.Parallel() + + _, err := parse(t, "tarantool = \"[ce]\"\ntt = \">=3.0.0\"\n") + require.Error(t, err) + }) + + t.Run("tcm with flavor is a validation error", func(t *testing.T) { + t.Parallel() + + mfst, err := parse(t, "tarantool = \">=3.0.0\"\ntt = \">=3.0.0\"\ntcm = \">=1.5.0[ee]\"\n") + require.NoError(t, err) // Parses; the flavor is generic at this layer. + + _, err = mfst.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "tcm") + }) + + t.Run("tcm without flavor is fine", func(t *testing.T) { + t.Parallel() + + platform := "tarantool = \">=3.0.0\"\ntt = \">=3.0.0\"\ntcm = \">=1.5.0,<2.0.0\"\n" + mfst, err := parse(t, platform) + require.NoError(t, err) + + _, err = mfst.Validate() + require.NoError(t, err) + }) +} + +// TestNamespaceTristate covers the three states of component.namespace. +func TestNamespaceTristate(t *testing.T) { + t.Parallel() + + src := ` +manifest_version = "0.1" +[package] +name = "pkg" +[platform] +tarantool = ">=3.0.0" +tt = ">=3.0.0" + +[components.absent] +path = "a/" + +[components.flat] +path = "b/" +namespace = "" + +[components.named] +path = "c/" +namespace = "custom" +` + mfst, _, err := manifest.ParseManifest([]byte(src)) + require.NoError(t, err) + + // Field absent: nil pointer, falls back to package name. + assert.Nil(t, mfst.Components["absent"].Namespace) + assert.Equal(t, "pkg", mfst.Components["absent"].EffectiveNamespace("pkg")) + + // Empty string: flat layout. + require.NotNil(t, mfst.Components["flat"].Namespace) + assert.Empty(t, *mfst.Components["flat"].Namespace) + assert.Empty(t, mfst.Components["flat"].EffectiveNamespace("pkg")) + + // Explicit string. + require.NotNil(t, mfst.Components["named"].Namespace) + assert.Equal(t, "custom", mfst.Components["named"].EffectiveNamespace("pkg")) +} + +const versionedBody = ` +[package] +name = "demo" +[platform] +tarantool = ">=3.0.0" +tt = ">=3.0.0" +` + +// TestFormatVersions tables the unknown-field and unknown-enum rules. +func TestFormatVersions(t *testing.T) { + t.Parallel() + + t.Run("same minor, unknown field fails", func(t *testing.T) { + t.Parallel() + + src := "manifest_version = \"0.1\"\nfuture_field = \"x\"\n" + versionedBody + _, _, err := manifest.ParseManifest([]byte(src)) + require.Error(t, err) + assert.Contains(t, err.Error(), "future_field") + }) + + t.Run("newer minor, unknown field warns and continues", func(t *testing.T) { + t.Parallel() + + src := "manifest_version = \"0.2\"\nfuture_field = \"x\"\n" + versionedBody + mfst, warnings, err := manifest.ParseManifest([]byte(src)) + require.NoError(t, err) + require.NotEmpty(t, warnings) + assert.Contains(t, warnings[0], "future_field") + assert.Equal(t, "demo", mfst.Package.Name) + }) + + t.Run("newer major is refused", func(t *testing.T) { + t.Parallel() + + src := "manifest_version = \"1.0\"\n" + versionedBody + _, _, err := manifest.ParseManifest([]byte(src)) + require.ErrorIs(t, err, manifest.ErrUnsupportedVersion) + }) + + t.Run("malformed version is rejected", func(t *testing.T) { + t.Parallel() + + src := "manifest_version = \"0.1.0\"\n" + versionedBody + _, _, err := manifest.ParseManifest([]byte(src)) + require.Error(t, err) + }) + + t.Run("unknown enum value fails on any version", func(t *testing.T) { + t.Parallel() + + for _, ver := range []string{"0.1", "0.2"} { + src := "manifest_version = \"" + ver + "\"\n" + versionedBody + + "\n[dependencies.x]\nsource = \"ftp\"\n" + mfst, _, err := manifest.ParseManifest([]byte(src)) + require.NoError(t, err, "ver %s parses", ver) + + _, err = mfst.Validate() + require.Error(t, err, "ver %s: ftp source must fail validation", ver) + assert.Contains(t, err.Error(), "ftp") + } + }) +} + +// TestValidateNegatives covers the structural validation failures. +func TestValidateNegatives(t *testing.T) { + t.Parallel() + + base := func(t *testing.T, body string) *manifest.Manifest { + t.Helper() + + src := "manifest_version = \"0.1\"\n[package]\nname = \"demo\"\n" + + "[platform]\ntarantool = \">=3.0.0\"\ntt = \">=3.0.0\"\n" + body + mfst, _, err := manifest.ParseManifest([]byte(src)) + require.NoError(t, err) + + return mfst + } + + t.Run("two default products", func(t *testing.T) { + t.Parallel() + + mfst := base(t, ` +[components.a] +path = "a/" +[products.one] +components = ["a"] +default = true +[products.two] +components = ["a"] +default = true +`) + _, err := mfst.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "default") + }) + + t.Run("product references unknown component", func(t *testing.T) { + t.Parallel() + + mfst := base(t, ` +[components.a] +path = "a/" +[products.p] +components = ["a", "ghost"] +`) + _, err := mfst.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "ghost") + }) + + t.Run("product name with ::", func(t *testing.T) { + t.Parallel() + + mfst := base(t, ` +[components.a] +path = "a/" +[products."ns::p"] +components = ["a"] +`) + _, err := mfst.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "::") + }) + + t.Run("reserved package name", func(t *testing.T) { + t.Parallel() + + src := "manifest_version = \"0.1\"\n[package]\nname = \"bin\"\n" + + "[platform]\ntarantool = \">=3.0.0\"\ntt = \">=3.0.0\"\n" + mfst, _, err := manifest.ParseManifest([]byte(src)) + require.NoError(t, err) + + _, err = mfst.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved") + }) + + t.Run("single product needs no default", func(t *testing.T) { + t.Parallel() + + mfst := base(t, ` +[components.a] +path = "a/" +[products.only] +components = ["a"] +`) + _, err := mfst.Validate() + require.NoError(t, err) + }) +} + +// TestManifestHashStable checks the hash is stable byte-for-byte and changes on +// any edit, including a comment. +func TestManifestHashStable(t *testing.T) { + t.Parallel() + + src := "manifest_version = \"0.1\"\n[package]\nname = \"demo\"\n" + + "[platform]\ntarantool = \">=3.0.0\"\ntt = \">=3.0.0\"\n" + + first, _, err := manifest.ParseManifest([]byte(src)) + require.NoError(t, err) + + second, _, err := manifest.ParseManifest([]byte(src)) + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(first.Hash(), "sha256:")) + assert.Equal(t, first.Hash(), second.Hash(), "identical bytes => identical hash") + + // A comment-only edit changes the raw bytes and therefore the hash. + withComment := "# a comment\n" + src + edited, _, err := manifest.ParseManifest([]byte(withComment)) + require.NoError(t, err) + assert.NotEqual(t, first.Hash(), edited.Hash(), "any edit changes the hash") +} diff --git a/cli/manifest/testdata/my-app.toml b/cli/manifest/testdata/my-app.toml new file mode 100644 index 000000000..3118f6f15 --- /dev/null +++ b/cli/manifest/testdata/my-app.toml @@ -0,0 +1,36 @@ +manifest_version = '0.1' + +[package] +name = 'my-app' +description = 'router service for Tarantool 3.x' +license = 'Apache-2.0' +authors = ['Alice '] + +[platform] +tarantool = '>=3.0.0,<4.0.0[ce]' +tt = '>=3.1.0' +platforms = ['linux-amd64', 'linux-arm64'] + +[dependencies] +[dependencies.luasocket] +source = 'registry' +version = '>=3.0.0,<4.0.0' + +[components] +[components.lua] +path = '.' +include = ['*.lua', 'lib/*.lua'] + +[components.native] +path = 'native/' +namespace = '' + +[components.native.build] +backend = 'lua-c' +module = 'fast_hash' +sources = ['fast_hash.c'] + +[products] +[products.default] +components = ['lua', 'native'] +default = true diff --git a/cli/manifest/validate.go b/cli/manifest/validate.go new file mode 100644 index 000000000..b36ec4c07 --- /dev/null +++ b/cli/manifest/validate.go @@ -0,0 +1,329 @@ +package manifest + +import ( + "fmt" + "regexp" + "sort" +) + +// nameRe is the identifier shape shared by package, component and product +// names: a lowercase letter followed by lowercase letters, digits and dashes. +var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +func isReservedPackageName(name string) bool { + switch name { + case "bin", "manifests": + return true + default: + return false + } +} + +func isPlatformToken(token string) bool { + switch token { + case "linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "any": + return true + default: + return false + } +} + +func isHookBackend(backend string) bool { + switch backend { + case backendMake, backendShell: + return true + default: + return false + } +} + +func isHookName(name string) bool { + switch name { + case "pre_build", "post_build": + return true + default: + return false + } +} + +// Validate walks the manifest structure and reports the first structural error +// it finds, always as a *ValidationError. The returned warnings are non-fatal: +// unknown hook names are skipped with a warning, since hooks are an extension +// point for later phases. +// +// Validation here is structural - fields present and consistent with each +// other. Whether a dependency actually resolves, the package version, and on-disk +// component files are checked further down the pipeline. +func (m *Manifest) Validate() ([]string, error) { + checks := []func() error{ + m.validateVersion, + m.validatePackage, + m.validatePlatform, + func() error { return validateDependencies("dependencies", m.Dependencies) }, + func() error { return validateDependencies("dev_dependencies", m.DevDependencies) }, + m.validateComponents, + m.validateProducts, + } + + for _, check := range checks { + err := check() + if err != nil { + return nil, err + } + } + + return m.validateHooks() +} + +func (m *Manifest) validateVersion() error { + if m.ManifestVersion == "" { + return invalid("manifest_version", "is required") + } + + ver, err := parseFormatVersion(m.ManifestVersion) + if err != nil { + return err + } + + if ver.major != ourManifestVersion.major { + return fmt.Errorf("manifest version %q %w (supports %d.x)", + ver, ErrUnsupportedVersion, ourManifestVersion.major) + } + + return nil +} + +func (m *Manifest) validatePackage() error { + name := m.Package.Name + switch { + case name == "": + return invalid("package.name", "is required") + case !nameRe.MatchString(name): + return invalid("package.name", "%q must match [a-z][a-z0-9-]*", name) + case isReservedPackageName(name): + return invalid("package.name", "%q is reserved", name) + } + + return nil +} + +func (m *Manifest) validatePlatform() error { + switch { + case m.Platform.Tarantool.Version == "": + return invalid("platform.tarantool", "is required") + case m.Platform.Tt.Version == "": + return invalid("platform.tt", "is required") + case m.Platform.Tcm.Flavor != "": + return invalid("platform.tcm", "must not have a flavor; TCM is Enterprise only") + } + + return m.validatePlatforms() +} + +func (m *Manifest) validatePlatforms() error { + platforms := m.Platform.Platforms + if platforms == nil { + return nil + } + + if len(platforms) == 0 { + return invalid("platform.platforms", "must be omitted or non-empty") + } + + hasAny := false + hasConcrete := false + + for _, p := range platforms { + if !isPlatformToken(p) { + return invalid("platform.platforms", "unknown token %q", p) + } + + if p == "any" { + hasAny = true + } else { + hasConcrete = true + } + } + + if hasAny && hasConcrete { + return invalid("platform.platforms", "%q cannot be mixed with concrete platforms", "any") + } + + return nil +} + +func validateDependencies(section string, deps map[string]Dependency) error { + for _, name := range sortedKeys(deps) { + err := validateDependency(section+"."+name, deps[name]) + if err != nil { + return err + } + } + + return nil +} + +func validateDependency(field string, dep Dependency) error { + switch dep.Source { + case sourceRegistry: + if dep.Version == "" { + return invalid(field, "version is required for source %q", sourceRegistry) + } + case sourcePath: + if dep.Path == "" { + return invalid(field, "path is required for source %q", sourcePath) + } + default: + return invalid(field, + "source %q is not supported in manifest_version %q (want registry or path)", + dep.Source, ManifestVersion) + } + + if dep.Kind != "" && dep.Kind != "library" { + return invalid(field, + "kind %q is not supported in manifest_version %q (want library)", + dep.Kind, ManifestVersion) + } + + return nil +} + +func (m *Manifest) validateComponents() error { + for _, name := range sortedKeys(m.Components) { + comp := m.Components[name] + + field := "components." + name + if !nameRe.MatchString(name) { + return invalid(field, "name must match [a-z][a-z0-9-]*") + } + + if comp.Path == "" { + return invalid(field+".path", "is required") + } + + err := validateDependencies(field+".dependencies", comp.Dependencies) + if err != nil { + return err + } + + if comp.Build != nil { + err := validateBuild(field+".build", *comp.Build) + if err != nil { + return err + } + } + } + + return nil +} + +func validateBuild(field string, build Build) error { + switch build.Backend { + case backendMake: + if build.MakeTarget == "" { + return invalid(field, "make_target is required for backend %q", backendMake) + } + case backendShell: + if build.Command == "" { + return invalid(field, "command is required for backend %q", backendShell) + } + case backendC, backendLuaC: + if build.Module == "" { + return invalid(field, "module is required for backend %q", build.Backend) + } + + if len(build.Sources) == 0 { + return invalid(field, "sources is required for backend %q", build.Backend) + } + case "": + return invalid(field, "backend is required") + default: + return invalid(field, + "backend %q is not supported in manifest_version %q (want make, shell, c or lua-c)", + build.Backend, ManifestVersion) + } + + return nil +} + +func (m *Manifest) validateProducts() error { + names := sortedKeys(m.Products) + defaults := 0 + + for _, name := range names { + field := "products." + name + if !nameRe.MatchString(name) { + // The "::" guard gets a dedicated message since it is reserved + // rather than merely malformed. + if regexp.MustCompile(`::`).MatchString(name) { + return invalid(field, + "must not contain %q (reserved for cross-package composition)", "::") + } + + return invalid(field, "name must match [a-z][a-z0-9-]*") + } + + p := m.Products[name] + if p.Default { + defaults++ + } + + for _, comp := range p.Components { + if _, ok := m.Components[comp]; !ok { + return invalid(field, "references unknown component %q", comp) + } + } + } + + if len(names) > 1 && defaults != 1 { + return invalid("products", + "exactly one product must have default = true (found %d)", defaults) + } + + return nil +} + +func (m *Manifest) validateHooks() ([]string, error) { + var warnings []string + + for _, name := range sortedKeys(m.Hooks) { + if !isHookName(name) { + warnings = append(warnings, fmt.Sprintf( + "unknown hook %q skipped (only pre_build and post_build are supported)", name)) + + continue + } + + hook := m.Hooks[name] + + backend := hook.Backend + if backend == "" { + backend = backendShell // Hooks default to shell. + } + + if !isHookBackend(backend) { + return warnings, invalid("hooks."+name, + "backend %q is not supported (want make or shell)", backend) + } + + hook.Backend = backend + + err := validateBuild("hooks."+name, hook) + if err != nil { + return warnings, err + } + } + + return warnings, nil +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + sort.Strings(keys) + + return keys +} diff --git a/cli/manifest/version.go b/cli/manifest/version.go new file mode 100644 index 000000000..1f254f5d0 --- /dev/null +++ b/cli/manifest/version.go @@ -0,0 +1,90 @@ +package manifest + +import ( + "strconv" + "strings" +) + +// versionFieldCount is the number of dot-separated fields in a format version. +const versionFieldCount = 2 + +// formatVersion is a parsed "." format version, used for both +// manifest_version and lock_version. +type formatVersion struct { + major int + minor int +} + +//nolint:gochecknoglobals // Parsed once from the version constants; effectively constants. +var ( + ourManifestVersion = mustParseFormatVersion(ManifestVersion) + ourLockVersion = mustParseFormatVersion(LockVersion) +) + +// parseFormatVersion parses exactly two non-negative integers separated by a +// dot. "0.1.0", "0", "x.y" and the like are errors. +func parseFormatVersion(raw string) (formatVersion, error) { + parts := strings.Split(raw, ".") + if len(parts) != versionFieldCount { + return formatVersion{}, invalid("", + "invalid format version %q: want \".\"", raw) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil || major < 0 { + return formatVersion{}, invalid("", "invalid format version %q: bad major", raw) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil || minor < 0 { + return formatVersion{}, invalid("", "invalid format version %q: bad minor", raw) + } + + return formatVersion{major: major, minor: minor}, nil +} + +func mustParseFormatVersion(raw string) formatVersion { + v, err := parseFormatVersion(raw) + if err != nil { + panic(err) + } + + return v +} + +func (v formatVersion) String() string { + return strconv.Itoa(v.major) + "." + strconv.Itoa(v.minor) +} + +type versionRelation int + +const ( + relSame versionRelation = iota // Same major, same minor. + relMinorNewer // Same major, newer minor. + relMinorOlder // Same major, older minor. + relMajorNewer // Newer major. + relMajorOlder // Older major. +) + +// relTo classifies v relative to the reference version ref. +func (v formatVersion) relTo(ref formatVersion) versionRelation { + switch { + case v.major > ref.major: + return relMajorNewer + case v.major < ref.major: + return relMajorOlder + case v.minor > ref.minor: + return relMinorNewer + case v.minor < ref.minor: + return relMinorOlder + default: + return relSame + } +} + +// minorNewerThan reports whether v has the same major but a strictly newer +// minor than ref - the only case where an unknown field is a warning rather +// than an error. +func (v formatVersion) minorNewerThan(ref formatVersion) bool { + return v.relTo(ref) == relMinorNewer +} diff --git a/go.mod b/go.mod index dd08d527d..96b069ccd 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/moby/term v0.5.2 github.com/nxadm/tail v1.4.11 github.com/otiai10/copy v1.14.1 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 From 78ee358ea606954b92837f1a82657ccca037169e Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 24 Jun 2026 09:28:02 +0300 Subject: [PATCH 2/2] ci: reuse cached Tarantool EE SDK on cache hit The Download Tarantool SDK step ran unconditionally, so a failed download.tarantool.io fetch (a 55-byte error blob, then a tar error) broke tests-ee even when a valid SDK was already restored from the actions/cache step. Gate the download on a cache miss so warm-cache runs reuse the cached SDK instead of re-fetching it. --- .github/actions/prepare-ee-test-env/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/prepare-ee-test-env/action.yml b/.github/actions/prepare-ee-test-env/action.yml index 93a202f52..e8b69c0ce 100644 --- a/.github/actions/prepare-ee-test-env/action.yml +++ b/.github/actions/prepare-ee-test-env/action.yml @@ -60,6 +60,10 @@ runs: key: ${{ matrix.sdk-version }} - name: Download Tarantool SDK + # Only download on a cache miss: the SDK is already restored from cache + # otherwise, and an unconditional re-download fails the whole job when + # download.tarantool.io is unavailable even though a good cache exists. + if: steps.cache-sdk.outputs.cache-hit != 'true' run: | ARCHIVE_NAME=tarantool-enterprise-sdk-${{ inputs.sdk-gc }}-${{ inputs.sdk-version }}.tar.gz ARCHIVE_PATH=$(echo ${{ inputs.sdk-version }} | sed -rn \