987e915bf2
New /launcher page: a grid of large tappable tiles, one per openable deployed app (subdomain presence is the single openability criterion, shared with the Megnyitás button via the extracted Server.subdomainMap helper). Colored tiles (deterministic slug color or .felhom.yml brand_color), white glyph/monogram fallback, target=_blank links for operational apps, greyed unclickable tiles for stopped ones. First sidebar item; / stays the Vezérlőpult. Universal app placeholder: new AppPlaceholderSVG served at /static/app-placeholder.svg, now the default FallbackIcon on app_list_row so a logo-less app shows a placeholder instead of visibility:hidden. Brand mark is never an app placeholder. New Metadata.BrandColor; new funcmap tileColor/initial. 10 new test functions + 4 red-proofs. No agent coupling; MinAgent unchanged.
416 lines
21 KiB
Go
416 lines
21 KiB
Go
package stacks
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Metadata holds app information parsed from .felhom.yml.
|
|
type Metadata struct {
|
|
DisplayName string `yaml:"display_name" json:"display_name"`
|
|
Description string `yaml:"description" json:"description"`
|
|
Category string `yaml:"category" json:"category"`
|
|
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
|
// BrandColor is an optional launcher tile color (#rgb or #rrggbb). Invalid values fall back to
|
|
// the deterministic slug-derived color (see web.tileColor); no app sets it yet.
|
|
BrandColor string `yaml:"brand_color,omitempty" json:"brand_color,omitempty"`
|
|
Slug string `yaml:"slug" json:"slug"`
|
|
// Lifecycle governs whether this app is OFFERED for new installs. It never affects an app that
|
|
// is already deployed — a customer running a hidden or abandoned app keeps full function, which
|
|
// is the whole point: removing a template from the catalog would orphan them instead.
|
|
// ""/"available" — normal.
|
|
// "hidden" — not offered for new installs. No explanation owed.
|
|
// "abandoned" — not offered for new installs, AND every box already running it shows a
|
|
// permanent notice that updates and security fixes will no longer arrive.
|
|
// An UNKNOWN value degrades to available with one WARN (see LoadMetadata) — a typo in a catalog
|
|
// push must never brick a template.
|
|
Lifecycle string `yaml:"lifecycle,omitempty" json:"lifecycle,omitempty"`
|
|
// OpenPath is appended to the app's public URL for the "Megnyitás" (open) link, for apps whose UI
|
|
// isn't at "/" (e.g. Gokapi → "/admin"). Empty = bare root. Must start with "/".
|
|
OpenPath string `yaml:"open_path,omitempty" json:"open_path,omitempty"`
|
|
Resources ResourceHints `yaml:"resources" json:"resources"`
|
|
DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"`
|
|
AppInfo AppInfo `yaml:"app_info" json:"app_info"`
|
|
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
|
|
HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"`
|
|
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
|
|
// InitialCreds: for apps that auto-generate a first-login credential into a file inside the
|
|
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
|
|
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
|
|
InitialCreds *InitialCredentials `yaml:"initial_credentials,omitempty" json:"initial_credentials,omitempty"`
|
|
// SMTPMapping declares how this app's compose env receives the managed app-email relay settings.
|
|
// Present only for apps that support outbound email; absent = the app has no email UI/injection.
|
|
SMTPMapping *SMTPMapping `yaml:"smtp_mapping,omitempty" json:"smtp_mapping,omitempty"`
|
|
// Backup is the referential-coupling classification block (Task 2). Present only for the 13
|
|
// bind-bearing apps; nil = legacy behavior (SQ5 two-level default). LoadMetadata REJECTS the whole
|
|
// block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
|
|
// degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
|
|
Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
|
|
}
|
|
|
|
// SMTPMapping renames the generic relay settings (host / port / security / from / from-name)
|
|
// to an app's specific env-var keys, plus any fixed Extra vars (e.g. accept-invalid-cert
|
|
// flags). When app-email is on (global + per-app) the controller injects these at
|
|
// deploy/redeploy: host = the in-controller shim, port = 2525, security = SecurityValue
|
|
// (the app's term for STARTTLS), from = <FromLocal>@<allowlisted-domain>. The values are
|
|
// NEVER persisted to app.yaml — they are derived from settings on every compose, so a
|
|
// toggle change applies on the next redeploy without rewriting secrets. (Spike §7.)
|
|
type SMTPMapping struct {
|
|
HostVar string `yaml:"host_var" json:"host_var"` // env key for the shim host (required)
|
|
PortVar string `yaml:"port_var" json:"port_var"` // env key for the port (required)
|
|
SecurityVar string `yaml:"security_var" json:"security_var"` // env key for the TLS mode (optional)
|
|
SecurityValue string `yaml:"security_value" json:"security_value"` // app term for STARTTLS (e.g. "starttls", "TLS")
|
|
FromVar string `yaml:"from_var" json:"from_var"` // env key for the From address (required)
|
|
FromNameVar string `yaml:"from_name_var" json:"from_name_var"` // env key for the From display name (optional)
|
|
FromLocal string `yaml:"from_local" json:"from_local"` // From local-part (defaults to the app slug)
|
|
Extra map[string]string `yaml:"extra" json:"extra"` // fixed extra env (accept-invalid-cert flags, etc.)
|
|
// TLSMode selects which shim listener the app is pointed at (which fixes the port):
|
|
// "" / "starttls" → :2525 (plaintext + STARTTLS advertised) — the default; existing apps unchanged.
|
|
// "plaintext" → :2526 (plaintext, STARTTLS NOT advertised) — for clients that opportunistically
|
|
// upgrade to STARTTLS and can't skip cert verification (cal.com, nextcloud).
|
|
// "implicit-tls" → :2465 (whole connection TLS).
|
|
TLSMode string `yaml:"tls_mode" json:"tls_mode"`
|
|
// FromDomainVar: for apps that SPLIT the From into local-part + domain env vars (nextcloud:
|
|
// MAIL_FROM_ADDRESS + MAIL_DOMAIN). When set, the controller injects FromVar=<local> and
|
|
// FromDomainVar=<allowlisted-domain> separately instead of FromVar=<local>@<domain>.
|
|
FromDomainVar string `yaml:"from_domain_var" json:"from_domain_var"`
|
|
}
|
|
|
|
// HasSMTPMapping reports whether this app declares a usable email mapping.
|
|
func (m *Metadata) HasSMTPMapping() bool {
|
|
return m.SMTPMapping != nil && m.SMTPMapping.HostVar != "" && m.SMTPMapping.FromVar != ""
|
|
}
|
|
|
|
// InitialCredentials tells the controller how to extract an app's auto-generated first-login
|
|
// credential from a file inside the running container. The file path is catalog-defined (trusted).
|
|
// Verification stays catalog-driven so any future self-seeding app can reuse the mechanism.
|
|
type InitialCredentials struct {
|
|
File string `yaml:"file" json:"file"` // path INSIDE the container
|
|
Format string `yaml:"format" json:"format"` // "json" | "regex" | "plain"
|
|
// Container overrides which container to read from; empty → the stack's main container.
|
|
Container string `yaml:"container,omitempty" json:"container,omitempty"`
|
|
// json format: which keys hold the username/password (password_key required; username optional).
|
|
UsernameKey string `yaml:"username_key,omitempty" json:"username_key,omitempty"`
|
|
PasswordKey string `yaml:"password_key,omitempty" json:"password_key,omitempty"`
|
|
// regex format: patterns whose first capture group is the value (password_pattern required).
|
|
UsernamePattern string `yaml:"username_pattern,omitempty" json:"username_pattern,omitempty"`
|
|
PasswordPattern string `yaml:"password_pattern,omitempty" json:"password_pattern,omitempty"`
|
|
// Note is shown alongside the credential (e.g. "initial password, change it after first login").
|
|
Note string `yaml:"note,omitempty" json:"note,omitempty"`
|
|
}
|
|
|
|
// AppInfo holds detailed app information for the info page.
|
|
type AppInfo struct {
|
|
Tagline string `yaml:"tagline" json:"tagline"`
|
|
UseCases []string `yaml:"use_cases" json:"use_cases"`
|
|
FirstSteps []string `yaml:"first_steps" json:"first_steps"`
|
|
Prerequisites []string `yaml:"prerequisites" json:"prerequisites"`
|
|
DefaultCreds string `yaml:"default_creds" json:"default_creds"`
|
|
DocsURL string `yaml:"docs_url" json:"docs_url"`
|
|
}
|
|
|
|
// OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers").
|
|
type OptionalConfigGroup struct {
|
|
Group string `yaml:"group" json:"group"`
|
|
Description string `yaml:"description" json:"description"`
|
|
Fields []OptionalConfigField `yaml:"fields" json:"fields"`
|
|
}
|
|
|
|
// OptionalConfigField defines an individual optional config field.
|
|
type OptionalConfigField struct {
|
|
EnvVar string `yaml:"env_var" json:"env_var"`
|
|
Label string `yaml:"label" json:"label"`
|
|
Type string `yaml:"type" json:"type"`
|
|
HelpURL string `yaml:"help_url" json:"help_url"`
|
|
HelpText string `yaml:"help_text" json:"help_text"`
|
|
}
|
|
|
|
// ResourceHints describe what the app needs.
|
|
type ResourceHints struct {
|
|
MemRequest string `yaml:"mem_request" json:"mem_request"`
|
|
MemLimit string `yaml:"mem_limit" json:"mem_limit"`
|
|
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
|
|
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
|
|
HungarianUI bool `yaml:"hungarian_ui" json:"hungarian_ui"`
|
|
}
|
|
|
|
// DeployField defines one configuration field shown during first deployment.
|
|
type DeployField struct {
|
|
EnvVar string `yaml:"env_var" json:"env_var"`
|
|
Label string `yaml:"label" json:"label"`
|
|
Type string `yaml:"type" json:"type"` // domain, subdomain, secret, password, path, text, select, boolean
|
|
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
|
|
Default string `yaml:"default" json:"default"`
|
|
Required bool `yaml:"required" json:"required"`
|
|
Placeholder string `yaml:"placeholder" json:"placeholder"`
|
|
Description string `yaml:"description" json:"description"`
|
|
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
|
|
Options []SelectOption `yaml:"options" json:"options,omitempty"`
|
|
// DataKey marks a field as a DATA-ENCRYPTING key (e.g. AdventureLog's "Titkosítási kulcs"):
|
|
// the app encrypts stored data with it, so regenerating it would render restored data
|
|
// unreadable. It is a fail-closed annotation only — the recovery unit never stores secrets;
|
|
// at restore the controller refuses (rather than silently restoring garbage) if a data_key
|
|
// app's key cannot be recovered from the guest's app.yaml (live or via PBS). See Phase 2.
|
|
DataKey bool `yaml:"data_key,omitempty" json:"data_key,omitempty"`
|
|
}
|
|
|
|
// DataKeyEnvVars returns the env-var names of fields marked data_key:true.
|
|
func (m *Metadata) DataKeyEnvVars() []string {
|
|
var out []string
|
|
for _, f := range m.DeployFields {
|
|
if f.DataKey {
|
|
out = append(out, f.EnvVar)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SelectOption is a choice for "select" type fields.
|
|
type SelectOption struct {
|
|
Value string `yaml:"value" json:"value"`
|
|
Label string `yaml:"label" json:"label"`
|
|
}
|
|
|
|
// IntegrationDef defines a single integration this app can provide to a target app.
|
|
type IntegrationDef struct {
|
|
Target string `yaml:"target" json:"target"` // target app slug: "filebrowser", "nextcloud"
|
|
Label string `yaml:"label" json:"label"` // UI label (Hungarian)
|
|
Description string `yaml:"description" json:"description"` // UI description
|
|
}
|
|
|
|
// HealthCheckConfig defines controller-side health probe configuration.
|
|
// When configured, the controller periodically probes the app's container
|
|
// and overrides the stack state to "unhealthy" if the service is not responding.
|
|
type HealthCheckConfig struct {
|
|
Interval string `yaml:"interval" json:"interval"` // e.g. "5m", "30s"; default "5m"
|
|
Checks []HealthCheckItem `yaml:"checks" json:"checks"`
|
|
}
|
|
|
|
// HealthCheckItem defines a single health check probe.
|
|
type HealthCheckItem struct {
|
|
Type string `yaml:"type" json:"type"` // "http", "api", "tcp"
|
|
Port int `yaml:"port" json:"port"`
|
|
Path string `yaml:"path" json:"path"` // for http/api; default "/"
|
|
Method string `yaml:"method" json:"method"` // for api; default "GET"
|
|
Expect *HealthCheckExpect `yaml:"expect,omitempty" json:"expect,omitempty"` // for api
|
|
}
|
|
|
|
// HealthCheckExpect defines expected response content for "api" type checks.
|
|
type HealthCheckExpect struct {
|
|
Status int `yaml:"status" json:"status"` // expected HTTP status code
|
|
BodyContains string `yaml:"body_contains" json:"body_contains"` // string that must appear in response body
|
|
}
|
|
|
|
// Lifecycle values. Absent/empty ≡ LifecycleAvailable.
|
|
const (
|
|
LifecycleAvailable = "available"
|
|
LifecycleHidden = "hidden"
|
|
LifecycleAbandoned = "abandoned"
|
|
)
|
|
|
|
// EffectiveLifecycle normalises Metadata.Lifecycle. It is the SINGLE definition of "what state is
|
|
// this app in" — every caller (catalog listing, deploy gate, badge, notice) must go through it, so
|
|
// an unknown value can only ever be interpreted one way.
|
|
//
|
|
// Fail-OPEN is deliberate here, and it is the opposite of the deploy gate's posture on purpose:
|
|
// an unrecognised value means the catalog is newer than this controller, and the safe reading of
|
|
// "I do not know what this state is" is "leave the app alone" — the alternative would let a typo,
|
|
// or a state added in a later release, silently pull a working app out of every customer's catalog.
|
|
// The gate that actually protects against installing something is `CanInstall`, and it is fed by
|
|
// this same function, so the two can never disagree.
|
|
func (m Metadata) EffectiveLifecycle() string {
|
|
switch m.Lifecycle {
|
|
case "", LifecycleAvailable:
|
|
return LifecycleAvailable
|
|
case LifecycleHidden:
|
|
return LifecycleHidden
|
|
case LifecycleAbandoned:
|
|
return LifecycleAbandoned
|
|
default:
|
|
return LifecycleAvailable
|
|
}
|
|
}
|
|
|
|
// VALUE receivers on all three, deliberately: html/template cannot call a POINTER-receiver method
|
|
// on a non-addressable value, and `.Meta` reaches the templates as a stacks.Metadata VALUE inside a
|
|
// map[string]interface{}. With a pointer receiver `{{if .Meta.IsAbandoned}}` fails at RENDER time
|
|
// with "can't evaluate field IsAbandoned in type interface {}" — which is a 500 on the page, not a
|
|
// compile error, so nothing catches it until someone loads the page. That is exactly how it shipped
|
|
// once (v0.158.0, caught live the same hour). Keep these value receivers.
|
|
//
|
|
// CanInstall reports whether this app may be offered/installed. The catalog listing and the deploy
|
|
// endpoint MUST both use this — a template excluded from the list but accepted by a direct POST
|
|
// would be a gate in name only.
|
|
func (m Metadata) CanInstall() bool { return m.EffectiveLifecycle() == LifecycleAvailable }
|
|
|
|
// IsAbandoned reports whether a DEPLOYED instance should carry the "no longer maintained" notice.
|
|
func (m Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned }
|
|
|
|
// LoadMetadata reads .felhom.yml from a stack directory.
|
|
// Returns default metadata if the file doesn't exist.
|
|
func LoadMetadata(stackDir string) Metadata {
|
|
meta := Metadata{}
|
|
|
|
path := filepath.Join(stackDir, ".felhom.yml")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
// No metadata file — build defaults from directory name
|
|
dirName := filepath.Base(stackDir)
|
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
meta.Slug = dirName
|
|
meta.Category = "tools"
|
|
return meta
|
|
}
|
|
|
|
if err := yaml.Unmarshal(data, &meta); err != nil {
|
|
log.Printf("[ERROR] [stacks] Failed to parse .felhom.yml in %s: %v", stackDir, err)
|
|
dirName := filepath.Base(stackDir)
|
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
meta.Slug = dirName
|
|
return meta
|
|
}
|
|
|
|
// Fill in defaults for missing fields
|
|
dirName := filepath.Base(stackDir)
|
|
if meta.Slug == "" {
|
|
meta.Slug = dirName
|
|
}
|
|
if meta.DisplayName == "" {
|
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
}
|
|
if meta.Category == "" {
|
|
meta.Category = "tools"
|
|
}
|
|
|
|
// Lifecycle: warn ONCE on an unrecognised value, then let EffectiveLifecycle degrade it to
|
|
// available. Logged here rather than in EffectiveLifecycle because that is called on every
|
|
// render — this is the one place per load, so a typo is visible without flooding the log.
|
|
if meta.Lifecycle != "" && meta.EffectiveLifecycle() == LifecycleAvailable && meta.Lifecycle != LifecycleAvailable {
|
|
log.Printf("[WARN] [stacks] %s: unknown lifecycle %q in .felhom.yml — treating as %q (known: %s, %s, %s)",
|
|
dirName, meta.Lifecycle, LifecycleAvailable, LifecycleAvailable, LifecycleHidden, LifecycleAbandoned)
|
|
}
|
|
|
|
// Default healthcheck fields
|
|
if meta.HealthCheck != nil {
|
|
if meta.HealthCheck.Interval == "" {
|
|
meta.HealthCheck.Interval = "5m"
|
|
}
|
|
for i := range meta.HealthCheck.Checks {
|
|
if meta.HealthCheck.Checks[i].Path == "" && (meta.HealthCheck.Checks[i].Type == "http" || meta.HealthCheck.Checks[i].Type == "api") {
|
|
meta.HealthCheck.Checks[i].Path = "/"
|
|
}
|
|
if meta.HealthCheck.Checks[i].Method == "" && meta.HealthCheck.Checks[i].Type == "api" {
|
|
meta.HealthCheck.Checks[i].Method = "GET"
|
|
}
|
|
}
|
|
}
|
|
|
|
// DOMAIN and SUBDOMAIN fields are always auto-filled/required — mark implicitly
|
|
for i := range meta.DeployFields {
|
|
if meta.DeployFields[i].Type == "domain" || meta.DeployFields[i].Type == "subdomain" {
|
|
meta.DeployFields[i].Required = true
|
|
meta.DeployFields[i].LockedAfterDeploy = true
|
|
}
|
|
// secret fields are always locked after deploy
|
|
if meta.DeployFields[i].Type == "secret" {
|
|
meta.DeployFields[i].LockedAfterDeploy = true
|
|
}
|
|
}
|
|
|
|
// Backup classification (Task 2): the SINGLE validation choke point. Catalog listing, the
|
|
// deployed-stack scan, and git-sync all flow through LoadMetadata, so a bad `backup:` block in a
|
|
// catalog push screams here within one sync cycle. On ANY defect (or an unreadable compose while a
|
|
// block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades
|
|
// to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes
|
|
// meta.Backup yet (Task 3/4).
|
|
if meta.Backup != nil {
|
|
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
|
binds := ParseComposeClassifiableBinds(composePath)
|
|
if _, err := os.Stat(composePath); err != nil {
|
|
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, err)
|
|
meta.Backup = nil
|
|
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
|
|
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
|
|
meta.Backup = nil
|
|
}
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
// ClassifiedBinds resolves the backup classification for a stack: it reads .felhom.yml (through the
|
|
// SAME LoadMetadata validation path, so a rejected block is already nil here) and its compose binds,
|
|
// then applies the two-level default via appbackup.ClassifyBinds. The bool reports whether the app
|
|
// carries a (valid) backup block at all. INERT — exists so Task 3 consumes a wired, end-to-end-tested
|
|
// seam instead of building one (the F-S3 lesson: wiring is where seams hide typos).
|
|
func (m *Manager) ClassifiedBinds(name string) ([]appbackup.ClassifiedBind, bool) {
|
|
// samba (R-7) is controller-generated infra with no .felhom.yml and absolute share binds — its
|
|
// classification comes from the shares registry instead (see samba_classify.go). Every other
|
|
// stack takes the unchanged catalog path below.
|
|
if name == SambaStackName {
|
|
return m.sambaClassifiedBinds()
|
|
}
|
|
stack, ok := m.GetStack(name)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
stackDir := filepath.Dir(stack.ComposePath)
|
|
meta := LoadMetadata(stackDir)
|
|
binds := ParseComposeClassifiableBinds(stack.ComposePath)
|
|
return appbackup.ClassifyBinds(meta.Backup, binds)
|
|
}
|
|
|
|
// HasDeployFields returns true if the app has any user-facing deploy fields
|
|
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
|
|
func (m *Metadata) HasDeployFields() bool {
|
|
for _, f := range m.DeployFields {
|
|
if f.Type != "domain" && f.Type != "secret" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UserFacingFields returns only fields the user needs to interact with.
|
|
// Excludes auto-filled (domain) and fully hidden (secret) fields.
|
|
func (m *Metadata) UserFacingFields() []DeployField {
|
|
var fields []DeployField
|
|
for _, f := range m.DeployFields {
|
|
if f.Type != "domain" && f.Type != "secret" {
|
|
fields = append(fields, f)
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// AutoGeneratedFields returns fields that are generated without user input.
|
|
func (m *Metadata) AutoGeneratedFields() []DeployField {
|
|
var fields []DeployField
|
|
for _, f := range m.DeployFields {
|
|
if f.Type == "secret" || f.Type == "domain" {
|
|
fields = append(fields, f)
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// HasAppInfo returns true if the metadata has any app info content.
|
|
func (m *Metadata) HasAppInfo() bool {
|
|
return m.AppInfo.Tagline != "" || len(m.AppInfo.UseCases) > 0 || len(m.AppInfo.FirstSteps) > 0
|
|
}
|
|
|
|
// HasOptionalConfig returns true if the metadata has any optional config groups.
|
|
func (m *Metadata) HasOptionalConfig() bool {
|
|
return len(m.OptionalConfig) > 0
|
|
}
|
|
|
|
// HasIntegrations returns true if the metadata defines any integrations.
|
|
func (m *Metadata) HasIntegrations() bool {
|
|
return len(m.Integrations) > 0
|
|
}
|