Version v0.5.0 of the documentation is no longer actively maintained. The site that you are currently viewing is an archived snapshot. For up-to-date documentation, see the latest version.

internal/config

import "github.com/dexadata/dexaflow/internal/config"

Package config loads Dexaflow configuration from defaults, an optional config file, and LEOFLOW_* environment variables, with flags taking precedence.

Index

Constants

Server roles (ADR 0049).

const (
    // RoleAll runs every component in one process (the default; Lite's only mode).
    RoleAll = "all"
    // RoleAPI runs the HTTP API + UI only (restricted network identity).
    RoleAPI = "api"
    // RoleScheduler runs the reconciler + dispatch + agent gRPC (privileged).
    RoleScheduler = "scheduler"
)

Auth providers (auth.provider allowlist). “jwt” is the default credential authenticator; “oidc” adds the SSO login flow on top of it (the JWT authenticator stays the request-path verifier in both modes).

const (
    // AuthProviderJWT is the default: username/password issues an HS256 token.
    AuthProviderJWT = "jwt"
    // AuthProviderOIDC enables the OIDC/SSO login flow. It is Pro-gated and
    // requires the auth.oidc.* configuration; boot fails closed otherwise.
    AuthProviderOIDC = "oidc"
)

Secret policy allowlists (ADR 0055). auth.secret_scoping and auth.secret_liveness_mode are validated against these; an unknown value fails boot closed. Empty is valid — serverDefaults sets the safe default for each.

const (
    // SecretScopingPermissive delivers the whole tenant vault (today's behavior),
    // scoping only where a DAG declared; the default.
    SecretScopingPermissive = "permissive"
    // SecretScopingEnforce delivers only the declared subset.
    SecretScopingEnforce = "enforce"
    // SecretScopingOff disables scope-by-declaration entirely.
    SecretScopingOff = "off"
    // SecretLivenessObserve logs a would-have-denied without denying; the default.
    SecretLivenessObserve = "observe"
    // SecretLivenessEnforce denies secret delivery when the caller's TI is not live.
    SecretLivenessEnforce = "enforce"
    // AgentTokenTransportEnvVar sets the agent token as a plaintext env var on the
    // pod spec (today's behavior); the default.
    AgentTokenTransportEnvVar = "envvar"
    // AgentTokenTransportExchange mounts a projected ServiceAccount token the agent
    // exchanges (via TokenReview) for a task-scoped JWT, so no bearer sits in
    // plaintext on the Pod object (ADR 0055 Fix #3). Pro/Kubernetes-executor-only.
    AgentTokenTransportExchange = "exchange"
)

HomeDirName is the per-user state directory under the home directory.

const HomeDirName = ".dexaflow"

LegacyHomeDirName is the state directory’s name before the rename.

const LegacyHomeDirName = ".leoflow"

func DefaultConfigFile

func DefaultConfigFile() (string, error)

DefaultConfigFile returns the default configuration file path, ~/.dexaflow/config.yaml (see HomeDir for installs from before the rename).

func HomeDir

func HomeDir() (string, error)

HomeDir returns the per-user state directory, ~/.dexaflow. See HomeDirIn.

func HomeDirIn

func HomeDirIn(userHome string) (string, error)

HomeDirIn returns the state directory under userHome. It is ~/.dexaflow, except for an install from before the rename: then ~/.dexaflow is created as a link to the existing ~/.leoflow, so its data stays where it is (a running Lite holds files and mounts under it) and both paths reach it. If the link cannot be created, ~/.dexaflow itself is returned, so state is never split between two directories. The directory is not created here.

func PersistSession

func PersistSession(path, serverURL, token string) error

PersistSession writes the control-plane server URL and auth token into the config file at path, preserving any other keys already there (e.g. the Lite settings written by `dexaflow setup`). It creates the file and its parent directory when absent, and keeps the file at 0600 because the token is a secret. An empty path is an error: the caller must resolve the target first.

type AuthSection

AuthSection configures authentication.

type AuthSection struct {
    Provider string     `mapstructure:"provider"`
    JWT      JWTSection `mapstructure:"jwt"`
    // OIDC configures the OIDC/SSO login flow. It is read only when Provider is
    // "oidc" (Pro-gated); the JWT authenticator remains the request-path verifier
    // in both modes.
    OIDC OIDCSection `mapstructure:"oidc"`
    // TrustedIssuer lets a platform that already authenticates its users open a
    // UI session for them with a token its own issuer signed (#1284). Empty
    // Issuer disables it.
    TrustedIssuer TrustedIssuerSection `mapstructure:"trusted_issuer"`
    // ServiceToken enables the operator service API under /api/v2/service/
    // (#1283), which creates tenants and links users to the trusted issuer. It
    // is the bearer credential for that API: at least 32 characters, kept in a
    // Secret. Empty disables the API.
    ServiceToken string `mapstructure:"service_token"`
    // ExternalSignInURL hands unauthenticated UI visitors to the operator's own
    // sign-in instead of Dexaflow's page, with the requested path in a `next`
    // query parameter (#1288). The operator's flow is expected to return them
    // with a Dexaflow session. Empty keeps Dexaflow's page; `?local=1` reaches it
    // either way.
    ExternalSignInURL string `mapstructure:"external_signin_url"`
    // ExternalSignOutURL is where sign-out lands after clearing the session, so
    // the operator can end their own session too (#1288). Empty returns to
    // Dexaflow's sign-in page.
    ExternalSignOutURL string `mapstructure:"external_signout_url"`
    // DevNoAuth disables authentication entirely, treating every request as an
    // admin. It exists ONLY for `dexaflow lite` (local, unsandboxed). It is false by
    // default and the server logs a prominent warning when it is on. NEVER set
    // this in production (LEOFLOW_AUTH_DEV_NO_AUTH).
    DevNoAuth bool `mapstructure:"dev_no_auth"`
    // LoginRateLimitPerMinute caps failed /auth/token attempts per client IP per
    // minute (anti-brute-force). Only failures count, so a successful login never
    // consumes budget. Lite raises this well above the production default because
    // it is a local single-user tool where lockouts are pure friction.
    LoginRateLimitPerMinute int `mapstructure:"login_rate_limit_per_minute"`
    // SecretScoping is the operator scope-by-declaration policy (ADR 0055 D9):
    // "permissive" | "enforce" | "off". permissive (the default) delivers the
    // whole tenant vault when a DAG declares nothing and warns — but still
    // delivers the whole vault — when a DAG declares a narrower set; enforce
    // delivers only the declared subset (empty declaration → nothing); off
    // disables scoping. It is operator-scoped, NEVER author-settable. Empty = the
    // permissive default.
    SecretScoping string `mapstructure:"secret_scoping"`
    // SecretLivenessMode gates secret delivery on task-instance liveness (ADR 0055
    // E2): "observe" | "enforce". observe (the default) logs + audits a
    // would-have-denied when the caller's TI is not live but still delivers;
    // enforce denies with PermissionDenied. Empty = the observe default.
    SecretLivenessMode string `mapstructure:"secret_liveness_mode"`
    // MaxAttemptCredentialLifetime is the hard ceiling on how long a single task
    // attempt's agent credential may be kept alive by heartbeat renewal (ADR 0055
    // Fix #4). Past this age since first dispatch, the control plane stops renewing
    // the token on heartbeat and lets it lapse, bounding a runaway attempt. The
    // short per-attempt TTL still bounds a stolen/finished token independently;
    // this caps the total renewed lifetime. It governs a second guarantee too: the
    // Kubernetes executor floors a task pod's activeDeadlineSeconds with it when
    // the DAG declares no execution_timeout, so a pod whose agent keeps retrying
    // its report through a total control-plane outage is not left Running
    // forever (a declared timeout is never shortened). Generous by default (24h)
    // so no normal task regresses. Bind via
    // LEOFLOW_AUTH_MAX_ATTEMPT_CREDENTIAL_LIFETIME as a duration (e.g. "24h",
    // "90m"). With warm pools enabled it is also the per-attempt watchdog that
    // keeps a wedged attempt from pinning a warm slot (a warm pod has no pod-level
    // deadline; the worker lifetime cap drains between attempts, never
    // mid-attempt). A non-positive value disables the renewal ceiling, the pod
    // deadline floor and that watchdog together — a wedged task then has no
    // wall-clock bound of its own — so boot logs a WARN naming the key.
    MaxAttemptCredentialLifetime time.Duration `mapstructure:"max_attempt_credential_lifetime"`
    // AgentTokenTransport selects how the in-pod agent obtains its control-plane
    // bearer credential (ADR 0055 Fix #3): "envvar" (the default) sets the token as
    // a plaintext LEOFLOW_AGENT_TOKEN env var on the pod spec — today's behavior,
    // byte-identical; "exchange" mounts a projected ServiceAccount token that the
    // agent exchanges once (via a control-plane TokenReview) for the task-scoped
    // JWT, so no bearer sits in plaintext on the Pod object. The exchange path is
    // Pro/Kubernetes-executor-only (the subprocess executor has no pod/SA/TokenReview
    // and ignores this). It is operator-scoped, NEVER author-settable. Empty = the
    // envvar default. Bind via LEOFLOW_AUTH_AGENT_TOKEN_TRANSPORT.
    AgentTokenTransport string `mapstructure:"agent_token_transport"`
    // SessionCookieInsecure drops the Secure attribute from the browser session
    // cookie (_token) and the OIDC state cookie. It is operator-scoped, NEVER
    // author-settable, and defaults to false, which is the hardened posture.
    //
    // There is exactly one reason to set it: a deployment served over plain http
    // to something that is not a loopback address. A browser refuses a Secure
    // cookie from such an origin outright, so the login page would post valid
    // credentials, get a 200, and land back on itself with no error anywhere. A
    // loopback deployment (localhost, 127.0.0.1) needs nothing: browsers treat it
    // as trustworthy and accept the Secure cookie over http.
    //
    // It cannot be derived from the request. Behind a TLS-terminating ingress the
    // server sees plain http while the browser sees https, so request-derived
    // Secure would strip it from the deployment that most needs it. Boot logs a
    // WARN when it is on. Bind via LEOFLOW_AUTH_SESSION_COOKIE_INSECURE.
    SessionCookieInsecure bool `mapstructure:"session_cookie_insecure"`
}

type CORSSection

CORSSection configures cross-origin access.

type CORSSection struct {
    AllowedOrigins []string `mapstructure:"allowed_origins"`
}

type Config

Config holds the developer CLI configuration.

type Config struct {
    // ServerURL is the control plane base URL used by push and auth create-token.
    ServerURL string `mapstructure:"server_url"`
    // Token is the JWT bearer token persisted by `dexaflow auth login` and used by
    // push and deploy when no --token flag or LEOFLOW_TOKEN env is set.
    Token string `mapstructure:"token"`
    // LogLevel is reserved for CLI log verbosity (not yet wired).
    LogLevel string `mapstructure:"log_level"`
    // Registry is reserved for the image registry used by image build (ADR 0003).
    Registry string `mapstructure:"registry"`
    // ParserCmd is the command used to invoke the Python parser from compile.
    ParserCmd string `mapstructure:"parser_cmd"`

    // Lite-edition settings written by `dexaflow setup` and read by `dexaflow lite`.
    // Workspace is the default directory holding the user's DAG projects.
    Workspace string `mapstructure:"workspace"`
    // LiteExecutor is the chosen executor: "subprocess" (local) or "k8s" (mini-cluster).
    LiteExecutor string `mapstructure:"lite_executor"`
    // LitePort is the UI/API port for the Lite control plane.
    LitePort int `mapstructure:"lite_port"`
    // AdminEmail is the Lite admin login created at bootstrap.
    AdminEmail string `mapstructure:"admin_email"`
    // AdminPasswordHash is the bcrypt hash of the generated admin password; the
    // plaintext is shown once at setup and never stored (Lite only).
    AdminPasswordHash string `mapstructure:"admin_password_hash"`
    // JWTSecret is the per-install Lite JWT signing secret, generated by
    // `dexaflow setup` (random, 64 hex chars) and persisted alongside the admin
    // hash. Rotating it on every fresh install invalidates browser tokens from a
    // prior install — so a reinstall greets the user with the login screen and
    // the freshly printed credentials actually do something (#121). Empty on
    // legacy installs; the lite runner falls back to the dev-only constant with
    // a warning so the upgrade does not break existing setups.
    JWTSecret string `mapstructure:"jwt_secret"`
    // SecretKey is the per-install key encrypting connection passwords and extra
    // at rest, generated by `dexaflow setup` (random, 64 hex chars). Lite used to
    // encrypt with a constant compiled into this repository, identical on every
    // install, so anyone holding a Lite database file read every credential in it
    // (#486). Empty on a legacy install; the lite runner then falls back to that
    // constant with a warning, and hands it to the server as a read-only
    // fallback so existing rows are re-encrypted rather than orphaned.
    SecretKey string `mapstructure:"secret_key"`
    // SecretKeyPrevious is a decrypt-only predecessor. Nothing writes it: it is a
    // hand-set escape hatch for an install whose key was changed by hand and
    // still has rows under the old one. Set it and Lite passes both keys to the
    // server, which reads with either and writes with the first (#486).
    SecretKeyPrevious string `mapstructure:"secret_key_previous"`
}

func Load

func Load(configFile string, flags *pflag.FlagSet) (*Config, error)

Load assembles configuration from defaults, the given file (when non-empty), LEOFLOW_* environment variables, and the provided flag set, in increasing order of precedence. A nil flag set or empty file path is ignored.

type DatabaseSection

DatabaseSection configures the Postgres connection pool.

type DatabaseSection struct {
    URL          string `mapstructure:"url"`
    MaxOpenConns int    `mapstructure:"max_open_conns"`
    MaxIdleConns int    `mapstructure:"max_idle_conns"`
}

type DispatchSection

DispatchSection sizes the BufferedDispatcher (#127). BufferSize=0 keeps the scheduler tick synchronous with the inner dispatcher — the right shape for Lite (subprocess fork is microseconds). BufferSize>0 enables the worker pool — the right shape for Pro (Kubernetes API calls add real latency). The defaults are set per-edition by configsetup so the user does not have to think about this; an operator can still tune the knobs.

type DispatchSection struct {
    // BufferSize is the depth of the queued-dispatches channel. 0 disables the
    // pool (synchronous passthrough). A full channel returns ErrAtCapacity to
    // the scheduler, which leaves the TI scheduled for the next tick.
    BufferSize int `mapstructure:"buffer_size"`
    // Workers is the number of goroutines draining the queue. Ignored when
    // BufferSize <= 0; otherwise floored to 1.
    Workers int `mapstructure:"workers"`
}

type ExecutionSection

ExecutionSection configures warm worker pools — Pro-gated N:1 pod reuse (ADR 0058). Every field is operator-set (never DAG-author-set), consistent with the secret-scoping stance: whether a pod may be reused across attempts is an operator’s security decision, not a DAG author’s. All fields default to a byte-for-byte no-op — warm pools OFF means dedicated pod-per-task, today’s behavior — and are read for runtime behavior only in a later brick; N1a introduces the knobs plus the fail-closed boot guard (validateExecution).

type ExecutionSection struct {
    // WarmPoolsEnabled turns on N:1 pod reuse (ADR 0058). Default false = a
    // dedicated pod per task attempt, today's behavior byte-for-byte. Turning it on
    // is gated at boot on the security prerequisites (token-exchange transport +
    // liveness enforcement) because a warm pod reuses one credential across attempts.
    WarmPoolsEnabled bool `mapstructure:"warm_pools_enabled"`
    // MaxAttemptsPerWorker caps how many attempts a warm worker serves before it is
    // drained and recycled (ADR 0058 D9). Bounds credential-leak and stale-image
    // exposure by forcing a fresh pod periodically. Default 50.
    MaxAttemptsPerWorker int `mapstructure:"max_attempts_per_worker"`
    // MaxWorkerLifetime is the wall-clock cap on a warm worker before it is drained
    // and recycled (ADR 0058 D9), independent of the attempt count. Default 1h. When
    // warm pools are on it MUST be >= auth.max_attempt_credential_lifetime, so a
    // worker is never force-recycled mid-attempt by its token lapsing.
    MaxWorkerLifetime time.Duration `mapstructure:"max_worker_lifetime"`
    // MinIdleWorkers is the number of warm workers kept ready per DAG version
    // (ADR 0058 D6). Default 0 = scale-to-zero, preserving the ADR 0002 zero-idle
    // floor; an operator opts into warmth by raising it.
    MinIdleWorkers int `mapstructure:"min_idle_workers"`
    // WorkerIdleTTL is how long an idle warm worker is kept before it is recycled
    // (ADR 0058 D6). Default 5m.
    WorkerIdleTTL time.Duration `mapstructure:"worker_idle_ttl"`
    // MaxPoolSize caps the total warm workers a single DAG version may hold —
    // registered workers plus in-flight dedicated pods. Default 8, operator-set.
    // N1b1-place records the knob and validates it (>= 1 when warm pools are on)
    // but does NOT enforce the cap yet: defer-at-max needs real pool accounting
    // (registered workers + in-flight pods), which arrives with the worker
    // lifecycle in N1b2/N1d. Today's placer is assign-if-free-else-dedicated.
    MaxPoolSize int `mapstructure:"max_pool_size"`
    // MaxWarmPodsPerTenant caps the TOTAL warm pods a single tenant may hold across
    // ALL its dag_versions on a shared cluster (M4). Where MaxPoolSize bounds one
    // dag_version's pool, this bounds a tenant's aggregate warm footprint so one
    // tenant cannot pin unlimited idle pods and starve neighbors on a shared
    // multi-team cluster. Default 100, operator-set. It is a RESERVE-then-RATION
    // budget, never a starvation lever: a tenant's promised idle floors (the sum of
    // its versions' EffectiveMinIdle) are honored even when they exceed this cap
    // (the reconciler raises the effective budget to the floor sum and meters the
    // misconfiguration), and the cap is enforced only by refusing to CREATE new
    // warm pods — never by deleting a busy worker.
    MaxWarmPodsPerTenant int `mapstructure:"max_warm_pods_per_tenant"`
}

func (ExecutionSection) EffectiveMinIdle

func (e ExecutionSection) EffectiveMinIdle(dagMinIdle int) int

EffectiveMinIdle resolves the warm-worker target for one dag_version under model A2 (ADR 0058 N1b2b): the DAG author declares desired warmth per DAG (dagMinIdle), the operator caps and floors it.

  • Warm pools OFF => always 0. This is what makes a default deploy a byte-for-byte no-op: with warmth gated off no warm pod is ever targeted, so the reconciler (when it runs at all) reconciles every pool to zero.
  • The DAG author’s value wins when set (> 0); when the DAG declares none (0) it falls back to the operator’s execution.min_idle_workers floor.
  • The resolved value is clamped to [0, max_pool_size] so an author can never provision more warmth than the operator’s per-version cap allows, and a nonsensical negative never underflows.

type ExecutorSection

ExecutorSection configures how tasks are executed.

type ExecutorSection struct {
    HTTP HTTPExecutorSection `mapstructure:"http"`
    // TaskNamespace is the Kubernetes namespace the server creates task pods and
    // per-run staging PVCs in. It MUST match the namespace the Helm chart grants
    // the executor Role in (chart `taskNamespace` → LEOFLOW_EXECUTOR_TASK_NAMESPACE);
    // a mismatch 403s every dispatch (#480). Defaults to "leoflow".
    TaskNamespace string `mapstructure:"task_namespace"`
    // Type selects the pod-path executor: "kubernetes" (default, pod-per-task) or
    // "subprocess" (dev only, runs the agent on the host without isolation, used
    // by `dexaflow lite`).
    Type string `mapstructure:"type"`
    // AgentPath is the leoflow-agent binary the subprocess executor runs (dev only).
    AgentPath string `mapstructure:"agent_path"`
    // SubprocessWorkDir is the working directory the subprocess executor runs the
    // agent in, so it can import the project's dag.py (dev only). Empty keeps the
    // server's working directory.
    SubprocessWorkDir string `mapstructure:"subprocess_workdir"`
    // AgentControlPlaneAddr is the gRPC address task pods dial back to. Empty
    // falls back to server.grpc_addr; in a local k3d/kind cluster set it to a
    // host-reachable address such as host.k3d.internal:9091.
    AgentControlPlaneAddr string `mapstructure:"agent_control_plane_addr"`
    // AgentTLSCAConfigMap names a ConfigMap (key ca.crt) mounted into task pods so
    // the agent verifies the control plane's gRPC TLS cert (issue #58). Empty =
    // agents use the insecure channel (dev).
    AgentTLSCAConfigMap string `mapstructure:"agent_tls_ca_configmap"`
    // TaskServiceAccount is the ServiceAccount task pods run as when a DAG's task
    // does not set execution.service_account. The chart wires its taskServiceAccount
    // here, so creating that SA makes keyless work without every DAG opting in.
    // Empty keeps pods on the namespace default SA (an explicit per-task value
    // always wins).
    TaskServiceAccount string `mapstructure:"task_service_account"`
    // TaskSecretName names a Kubernetes Secret mounted (read-only) into every task
    // pod at TaskSecretMountPath. It lets a task read a credential that lives in
    // the cluster's secret store (e.g. a GCP service-account key) referenced by a
    // connection's key_path — so Dexaflow never stores the key itself (ADR 0035).
    // Empty = no secret mounted.
    TaskSecretName string `mapstructure:"task_secret_name"`
    // TaskSecretMountPath is where TaskSecretName is mounted in the task pod.
    TaskSecretMountPath string `mapstructure:"task_secret_mount_path"`
    // Defaults holds per-cluster task defaults applied at dispatch to fill gaps the
    // DAG artifact left empty (ADR 0023, layer L0). They never override a value
    // baked into dag.json, keeping the artifact portable across clusters.
    Defaults PlatformDefaultsSection `mapstructure:"defaults"`
}

type HTTPExecutorSection

HTTPExecutorSection configures HTTP-related executor knobs.

type HTTPExecutorSection struct {
    // UserAgent is the default User-Agent header for HTTP requests a task image
    // may make on the platform's behalf.
    UserAgent string `mapstructure:"user_agent"`
}

HomeLinkSection is the operator’s way back from the UI: a label and the absolute http(s) URL it opens, in the same tab. Both are set or neither.

type HomeLinkSection struct {
    // Label is the link text, for example the operator's portal name.
    Label string `mapstructure:"label"`
    // URL is where the link goes. It must be an absolute http:// or https:// URL.
    URL string `mapstructure:"url"`
}

type JWTSection

JWTSection configures JWT issuance and validation.

type JWTSection struct {
    Secret          string `mapstructure:"secret"`
    TokenTTLSeconds int    `mapstructure:"token_ttl_seconds"`
    // MaxLifetimeSeconds is the hard ceiling on how long a user session may be kept
    // alive by transparent token renewal (aresta #5), measured since first login
    // (the token's oiat claim). Past it, POST /api/v2/auth/token/renew is refused
    // and the user must `dexaflow auth login` again. The short TokenTTLSeconds still
    // bounds a stolen token independently; this only caps the total renewed
    // lifetime, mirroring auth.max_attempt_credential_lifetime for agent tokens.
    // Generous by default (24h) so a normal dev day never re-logs in mid-session; a
    // non-positive value disables the ceiling (renewal never expires the session).
    // Bind via LEOFLOW_AUTH_JWT_MAX_LIFETIME_SECONDS.
    MaxLifetimeSeconds int `mapstructure:"max_lifetime_seconds"`
}

type LogsSection

LogsSection configures task log shipping.

type LogsSection struct {
    // Dir is the root directory for the disk log sink (the default backend).
    Dir string `mapstructure:"dir"`
    // Backend selects the durable task-log store: "disk" (default) writes files
    // under Dir; "s3" ships each attempt to an S3-compatible bucket (AWS S3,
    // MinIO, Ceph RGW); "gcs" ships to Google Cloud Storage via its native SDK.
    // Object storage is opt-in — Lite and every deployment that does not set this
    // keep the exact on-disk path unchanged.
    Backend string `mapstructure:"backend"`
    // Sink configures the object-store backend; read only when Backend is "s3" or
    // "gcs".
    Sink ObjectLogSection `mapstructure:"sink"`
}

type OIDCSection

OIDCSection configures the OIDC/SSO login flow (Authorization Code + PKCE). It is read only when auth.provider is “oidc”, which is Pro-gated and fails boot closed unless Issuer, ClientID, and RedirectURL are all set.

Verification is keyless: the ID token is validated against the issuer’s public JWKS discovered from Issuer, so no secret is stored for the verify path. ClientSecret is used solely for the authorization-code exchange and is injected via LEOFLOW_AUTH_OIDC_CLIENT_SECRET (env, never persisted, never logged) — the same posture as the JWT secret.

type OIDCSection struct {
    // Issuer is the org's single-tenant issuer URL (https). It is pinned: any ID
    // token whose iss claim differs is rejected (fail-closed tenant pin).
    Issuer string `mapstructure:"issuer"`
    // ClientID is the registered application (client) id; it is the expected
    // audience of every ID token.
    ClientID string `mapstructure:"client_id"`
    // ClientSecret is used only for the code exchange. Set via
    // LEOFLOW_AUTH_OIDC_CLIENT_SECRET; never persist it in a config file.
    ClientSecret string `mapstructure:"client_secret"`
    // RedirectURL is this server's callback URL registered with the IdP
    // (…/api/v2/auth/oidc/callback).
    RedirectURL string `mapstructure:"redirect_url"`
    // Scopes are the OAuth scopes requested; defaults to openid, email, profile.
    // Add the IdP's groups scope here when group→role mapping is used.
    Scopes []string `mapstructure:"scopes"`
    // GroupsClaim is the ID-token claim carrying the user's IdP groups (default
    // "groups"). Its values drive RoleMappings.
    GroupsClaim string `mapstructure:"groups_claim"`
    // RoleMappings maps an IdP group value to an existing Dexaflow role name.
    // Default-DENY: a group with no mapping grants no role. Configure via a YAML
    // config file only. The chart ships none today, so this map has no route
    // through Helm (#1143).
    //
    // Decoded OUT-OF-BAND (mapstructure:"-"), not by viper: viper's "." key
    // delimiter splits a dotted MAP KEY (a dotted IdP group like "app.admins")
    // into nested maps and fails to decode. LoadServer parses this map straight
    // from the raw YAML instead (#826). Env-var binding never applied to maps.
    RoleMappings map[string]string `mapstructure:"-"`
    // DefaultRole softens the default-deny WITHOUT weakening the secure default:
    // when an authenticated user resolves to zero mapped roles and DefaultRole is
    // set, they are granted this single role (operators are advised to use a
    // read-only role such as "viewer"). Empty (the default) keeps strict
    // default-deny — an unmapped user gets no role. It must name an existing DB
    // role for the resolved tenant; an unknown role fails the login closed.
    DefaultRole string `mapstructure:"default_role"`
    // TenantClaim selects which IdP claim identifies the tenant: "tid" (Entra) or
    // "hd" (Google Workspace).
    TenantClaim string `mapstructure:"tenant_claim"`
    // TenantClaims maps a TenantClaim value to a Dexaflow tenant name. A value not
    // present here is rejected (403) — the login never falls back to "default".
    //
    // Decoded OUT-OF-BAND (mapstructure:"-"), not by viper: a Google Workspace
    // `hd` value is a domain (always dotted, e.g. "example.com" or
    // "sub.example.co.uk"), which viper's "." delimiter would split into nested
    // maps and fail to decode — the #826 crash. LoadServer parses this map from
    // the raw YAML instead.
    TenantClaims map[string]string `mapstructure:"-"`
    // AllowedEmailDomains is an install-time, login-level allowlist layered on TOP
    // of the tid/hd tenant pin — it is NOT the pin itself (that stays issuer +
    // tid/hd + email_verified per D6). The check runs only AFTER the pin and
    // email_verified==true have passed, so the email domain is trustworthy at that
    // point. Empty (the default) imposes no domain restriction — the tid/hd pin is
    // the sole boundary. Non-empty admits a login (pre-provisioned OR JIT) only
    // when the verified email's domain is in the list; every other login is
    // rejected 403. It gates EVERY OIDC login, not just auto-provisioning.
    AllowedEmailDomains []string `mapstructure:"allowed_email_domains"`
    // BreakGlassEmails is the allowlist of local password logins permitted while
    // provider is "oidc"; every other password login is rejected (SSO-only).
    BreakGlassEmails []string `mapstructure:"break_glass_emails"`
    // JITProvisioning creates a user row on first OIDC login when no matching one
    // exists; the new row is granted the roles from RoleMappings. OFF by default.
    //
    // OFF denies every first login. A login matches a user only by
    // (oidc_provider, oidc_subject) and CreateOIDCUser, reached only from this
    // path, is the sole statement that writes those columns, so no API, CLI or
    // migration can pre-create an OIDC identity (ADR 0057, amendment on D4).
    // cmd/dexaflow-server warns about this at boot.
    JITProvisioning bool `mapstructure:"jit_provisioning"`
    // AutoRedirect starts the login flow on the sign-in page instead of rendering
    // it, for a deployment where that page is a screen to acknowledge for nothing
    // (an edge proxy has already authenticated, or SSO is the only way in).
    //
    // OFF by default: turning it on for everyone would remove the sign-in page
    // from deployments that rely on it. It is suppressed on a refused sign-on and
    // behind an explicit ?local=1, so a denial still lands somewhere readable and
    // a break-glass account can always reach the form.
    AutoRedirect bool `mapstructure:"auto_redirect"`
    // ClockSkewSeconds is the tolerance applied to the ID token's exp/iat/nbf
    // checks to absorb small clock differences between the IdP and this server.
    // Defaults to 60.
    ClockSkewSeconds int `mapstructure:"clock_skew_seconds"`
}

type OTelSection

OTelSection configures OpenTelemetry export.

type OTelSection struct {
    Enabled  bool   `mapstructure:"enabled"`
    Endpoint string `mapstructure:"endpoint"`
}

type ObjectLogSection

ObjectLogSection configures the object-store log backend for both the “s3” and “gcs” providers. Auth is keyless-first (ADR 0035): leave the credential fields empty to use the ambient chain — IRSA / instance profile for S3, GKE Workload Identity (ADC) for GCS. Static keys and credential files are a discouraged escape hatch for dev and clusters without an identity broker.

Bucket and Prefix apply to both providers. Region, Endpoint, ForcePathStyle, AccessKeyID and SecretAccessKey are S3-only. CredentialsFile is GCS-only. A field set for the other provider is simply ignored.

type ObjectLogSection struct {
    // Bucket is the target bucket. Required when Backend is "s3" or "gcs".
    Bucket string `mapstructure:"bucket"`
    // Prefix is an optional key prefix under which attempt objects are laid out.
    Prefix string `mapstructure:"prefix"`
    // Region is the S3 store region (e.g. "us-east-1"). Required by AWS S3;
    // ignored by some S3-compatible stores. S3-only.
    Region string `mapstructure:"region"`
    // Endpoint overrides the S3 endpoint for S3-compatible stores (MinIO, Ceph
    // RGW). Empty uses the AWS default endpoint. S3-only — it is NOT the way to
    // reach GCS, which has its own keyless "gcs" backend.
    Endpoint string `mapstructure:"endpoint"`
    // ForcePathStyle uses path-style addressing (bucket in the path, not the
    // host). Required by MinIO and some S3-compatible stores. S3-only.
    ForcePathStyle bool `mapstructure:"force_path_style"`
    // AccessKeyID is a static S3 access key. Empty (recommended) uses the keyless
    // credential chain (ADR 0035). S3-only.
    AccessKeyID string `mapstructure:"access_key_id"`
    // SecretAccessKey pairs with AccessKeyID. Discouraged; prefer keyless. S3-only.
    SecretAccessKey string `mapstructure:"secret_access_key"`
    // CredentialsFile is a path to a GCS service-account JSON key. Empty
    // (recommended) uses Application Default Credentials — GKE Workload Identity
    // keyless. GCS-only.
    CredentialsFile string `mapstructure:"credentials_file"`
}

type ObservabilitySection

ObservabilitySection configures logging, metrics, and tracing.

type ObservabilitySection struct {
    OTel      OTelSection `mapstructure:"otel"`
    LogLevel  string      `mapstructure:"log_level"`
    LogFormat string      `mapstructure:"log_format"`
}

type PlatformDefaultsSection

PlatformDefaultsSection configures the lowest-precedence (L0) task defaults, applied at dispatch to fill gaps the DAG left empty (ADR 0023).

type PlatformDefaultsSection struct {
    // StagingSize/StagingStorageClass default the per-run staging volume when the
    // DAG enabled staging without pinning them (e.g. the cluster's RWX class).
    StagingSize         string `mapstructure:"staging_size"`
    StagingStorageClass string `mapstructure:"staging_storage_class"`
    // StagingAccessMode is the PVC access mode for the staging volume. Defaults to
    // ReadWriteMany (multi-node prod); single-node dev (k3d local-path, no RWX)
    // sets ReadWriteOnce, which is sufficient for a run's sequential same-node pods.
    StagingAccessMode string `mapstructure:"staging_access_mode"`
    // ResourcesCPU/ResourcesMemory default a task's request when neither the task
    // override nor the DAG set any (Kubernetes quantities, e.g. "250m"/"256Mi").
    ResourcesCPU    string `mapstructure:"resources_cpu"`
    ResourcesMemory string `mapstructure:"resources_memory"`
    // RunTasksAsNonRoot refuses to start a task container whose image resolves
    // to UID 0, completing Pod Security Admission's `restricted` set. On by
    // default now that the images this repo ships carry a numeric non-root UID:
    // runtime/Dockerfile runs as `USER 65532:65532` and every examples/*/image
    // inherits it, and the executor pairs it with a pod-level fsGroup so the
    // per-run staging PVC stays writable. Turn it off for a cluster whose task
    // images legitimately run as root.
    //
    // Deliberately a cluster setting rather than a DAG field: whether untrusted
    // task code may run as root belongs to whoever operates the cluster, not to
    // whoever authors the DAG.
    RunTasksAsNonRoot bool `mapstructure:"run_tasks_as_non_root"`
    // ReadOnlyTaskRootFilesystem mounts every task container's root filesystem
    // read-only. Off by default because `restricted` does not require it and it
    // breaks ordinary Python tasks (pip cache, /tmp, matplotlib config); turn it
    // on for a fleet of tasks known not to write outside their volumes.
    ReadOnlyTaskRootFilesystem bool `mapstructure:"read_only_task_root_filesystem"`
}

type RedisSection

RedisSection configures the Redis connection.

type RedisSection struct {
    URL string `mapstructure:"url"`
    // CAFile is the absolute path to a PEM CA bundle the client trusts when
    // negotiating TLS to a `rediss://` URL (#312). Required to reach managed
    // Redis (Memorystore SERVER_AUTHENTICATION, ElastiCache in-transit
    // encryption, Azure Cache for Redis) whose server cert is signed by a
    // provider / per-instance CA that is not in the container's system
    // roots. Empty falls back to the SDK default — system roots only.
    // The Helm chart sets this via LEOFLOW_REDIS_CA_FILE when
    // `redis.caConfigMap` is configured, pointing at the mounted ConfigMap.
    CAFile string `mapstructure:"ca_file"`
}

type SchedulerSection

SchedulerSection configures the scheduler loop.

type SchedulerSection struct {
    LoopIntervalMS int             `mapstructure:"loop_interval_ms"`
    Enabled        bool            `mapstructure:"enabled"`
    Dispatch       DispatchSection `mapstructure:"dispatch"`
}

type SecretsSection

SecretsSection configures the external secrets backend (ADR 0060). When Backend is set, a Connection/Variable a DAG declares can be resolved pod-side from the provider store under the pod’s keyless identity instead of the leoflow vault. Empty (the default) keeps the vault as the only source — byte-identical to pre-0060. This is operator-only config: it is delivered to the pod as LEOFLOW_SECRETS_* env, which an author’s task env can never set (#828).

type SecretsSection struct {
    // Backend is the provider secrets-backend class the in-pod resolver drives
    // (e.g. the Airflow AWS SecretsManagerBackend). Empty disables external secrets.
    Backend string `mapstructure:"backend"`
    // BackendKwargs is the provider kwargs as a JSON object string (connections_prefix,
    // variables_prefix, region_name, …); a kind is served iff its `*_prefix` kwarg is
    // present. A JSON string (not a map) so it is settable via a single
    // LEOFLOW_SECRETS_BACKEND_KWARGS env var, matching the env-only control-plane
    // chart. Empty is treated as `{}`. Delivered verbatim to the pod.
    BackendKwargs string `mapstructure:"backend_kwargs"`
}

type ServerConfig

ServerConfig is the full configuration for the leoflow-server control plane. It mirrors the nested YAML described in the Phase 2 prompt.

type ServerConfig struct {
    Server        ServerSection        `mapstructure:"server"`
    Database      DatabaseSection      `mapstructure:"database"`
    Redis         RedisSection         `mapstructure:"redis"`
    Auth          AuthSection          `mapstructure:"auth"`
    Scheduler     SchedulerSection     `mapstructure:"scheduler"`
    Executor      ExecutorSection      `mapstructure:"executor"`
    Execution     ExecutionSection     `mapstructure:"execution"`
    Logs          LogsSection          `mapstructure:"logs"`
    Observability ObservabilitySection `mapstructure:"observability"`
    UI            UISection            `mapstructure:"ui"`
    Secrets       SecretsSection       `mapstructure:"secrets"`
    // SecretKey (LEOFLOW_SECRET_KEY) encrypts connection secrets at rest (ADR
    // 0019). Raw 32 chars, 64-char hex, or base64. Empty disables connection
    // writes.
    //
    // A COMMA-SEPARATED LIST rotates the key: the first entry encrypts and
    // decrypts, every later entry only decrypts. Nothing is ever written under a
    // later entry. This is the shape Airflow's `fernet_key` uses, so an operator
    // coming from Airflow already knows to put the new key first and the old
    // ones after (#486).
    //
    // Trying keys in order is safe only because AES-GCM is authenticated: a
    // wrong key fails to open rather than returning plausible garbage.
    SecretKey string `mapstructure:"secret_key"`
}

func LoadServer

func LoadServer(configFile string, flags *pflag.FlagSet) (*ServerConfig, error)

LoadServer assembles the server configuration from defaults, the given file, LEOFLOW_* environment variables, and flags, in increasing precedence.

func (*ServerConfig) Validate

func (c *ServerConfig) Validate() error

Validate reports configuration errors that must abort startup.

type ServerSection

ServerSection configures the HTTP, metrics, and agent gRPC listeners.

type ServerSection struct {
    // Role selects which components this process runs (ADR 0049): "all" (default;
    // the monolith Lite always runs), "api" (HTTP + UI, restricted identity), or
    // "scheduler" (reconciler + dispatch + agent gRPC, privileged). Splitting is a
    // Pro-only topology; "all" is behavior-identical to the pre-0049 monolith.
    Role        string      `mapstructure:"role"`
    HTTPAddr    string      `mapstructure:"http_addr"`
    MetricsAddr string      `mapstructure:"metrics_addr"`
    GRPCAddr    string      `mapstructure:"grpc_addr"`
    CORS        CORSSection `mapstructure:"cors"`
    // TrustedProxies lists the proxy IPs/CIDRs whose X-Forwarded-For is honored
    // when resolving the client IP. Empty (the default) trusts no proxy, so a
    // spoofed XFF cannot forge the client IP (audit H1); set it to the ingress
    // CIDR when the API runs behind a reverse proxy so rate-limiting and audit
    // see the real client.
    TrustedProxies []string `mapstructure:"trusted_proxies"`
    // GRPCTLSCert/GRPCTLSKey enable TLS on the agent gRPC listener (issue #58).
    // When both are set the channel is encrypted; empty means plaintext (dev).
    GRPCTLSCert string `mapstructure:"grpc_tls_cert"`
    GRPCTLSKey  string `mapstructure:"grpc_tls_key"`
}

func (ServerSection) EffectiveRole

func (s ServerSection) EffectiveRole() string

EffectiveRole returns the configured role, defaulting empty to RoleAll so an unset role (Lite, and every pre-0049 deployment) keeps the monolith behavior.

func (ServerSection) ServesAPI

func (s ServerSection) ServesAPI() bool

ServesAPI reports whether this process runs the HTTP API + UI.

func (ServerSection) ServesScheduler

func (s ServerSection) ServesScheduler() bool

ServesScheduler reports whether this process runs the scheduler, dispatch, and the agent gRPC endpoint.

type TrustedIssuerSection

TrustedIssuerSection configures one trusted external issuer (#1284). Its tokens are verified against its published JWKS and name an existing user, linked by (issuer:<name>, subject), in an allowed tenant; they never create users or grant roles.

type TrustedIssuerSection struct {
    // Name identifies the issuer; its users are linked under "issuer:<name>".
    // Lowercase letters, digits and '-'. Keep it stable once users exist.
    Name string `mapstructure:"name"`
    // Issuer is the exact `iss` the tokens carry.
    Issuer string `mapstructure:"issuer"`
    // JWKSURL is where the issuer publishes its public signing keys: https, or
    // http on a loopback host for local development.
    JWKSURL string `mapstructure:"jwks_url"`
    // Audience is the `aud` the tokens must carry for this Dexaflow.
    Audience string `mapstructure:"audience"`
    // TenantClaim names the string claim carrying the Dexaflow tenant name.
    TenantClaim string `mapstructure:"tenant_claim"`
    // AllowedTenants lists the tenants the issuer may sign in to; "*" allows
    // every tenant.
    AllowedTenants []string `mapstructure:"allowed_tenants"`
    // MaxLifetimeSeconds caps exp - iat of a token, the replay window of a
    // handoff. Zero uses the 120-second default; at most 600.
    MaxLifetimeSeconds int `mapstructure:"max_lifetime_seconds"`
    // AllowedOrigins are the origins (scheme://host[:port]) whose pages may
    // post a handoff. Any other Origin, or none, is refused, so another site
    // cannot sign a visitor in (login CSRF). Required.
    AllowedOrigins []string `mapstructure:"allowed_origins"`
}

func (TrustedIssuerSection) Enabled

func (s TrustedIssuerSection) Enabled() bool

Enabled reports whether a trusted issuer is configured.

type UISection

UISection configures the embedded Airflow UI.

type UISection struct {
    // InstanceName is shown in the UI navbar (Airflow's instance_name). Empty
    // falls back to "Dexaflow"; `dexaflow lite` sets it to mark the environment.
    InstanceName string `mapstructure:"instance_name"`
    // AutoRefreshIntervalSeconds is the SPA's polling cadence for DAG /
    // DagRun / task-instance state refresh (Airflow's auto_refresh_interval).
    // Zero (the default) falls back to api.DefaultUIAutoRefreshIntervalSeconds
    // (30s, production-safe). `dexaflow lite` sets it to 1s for a snappy inner
    // loop so the SPA reflects state changes almost immediately during dev.
    AutoRefreshIntervalSeconds int `mapstructure:"auto_refresh_interval_seconds"`
    // Edition marks the running edition; "lite" shows the silver LITE badge and
    // "pro" shows the gold PRO badge in the UI shell (independent of the auth
    // mode). Empty/any other value shows no badge — Demo intentionally renders
    // without an edition pill.
    Edition string `mapstructure:"edition"`
    // Workspace is the DAG project directory the Lite web editor edits (ADR 0025).
    // Empty disables the editor (Production, or Lite without one).
    Workspace string `mapstructure:"workspace"`
    // MonacoDir is where the pinned Monaco bundle was fetched by `dexaflow setup`;
    // the editor page is served Monaco from it. Empty shows a setup hint.
    MonacoDir string `mapstructure:"monaco_dir"`
    // HomeLink is an optional, persistent link from the UI back to the platform
    // the operator serves Dexaflow from (#1290). Empty shows no link.
    HomeLink HomeLinkSection `mapstructure:"home_link"`
    // Theme is a JSON object in the shape of Airflow's `[api] theme` (#1289):
    // `tokens` (Chakra design tokens, such as colors.brand and fonts),
    // `globalCss`, `icon` and `icon_dark_mode`. The UI applies it through its
    // own theming. Empty keeps the stock look.
    Theme string `mapstructure:"theme"`
    // FaviconURL replaces the UI's favicon. It must be http(s) or root-relative.
    FaviconURL string `mapstructure:"favicon_url"`
    // StylesheetURLs are extra stylesheets loaded by every UI page, typically
    // the web fonts a theme's fonts tokens name. Each must be http(s) or
    // root-relative.
    StylesheetURLs []string `mapstructure:"stylesheet_urls"`
}

Generated by gomarkdoc