diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml index a6d440484..6c5327eea 100644 --- a/.github/workflows/e2e-nightly.yml +++ b/.github/workflows/e2e-nightly.yml @@ -342,6 +342,14 @@ jobs: | tar -C "${ARTIFACT_DIR}/vm-console-node${idx}" -xf - 2>/dev/null ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo journalctl -u "spinifex-*" --no-pager --output=short-iso --since "60 min ago"' \ > "${ARTIFACT_DIR}/spinifex-journal-node${idx}.log" 2>/dev/null + ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo journalctl -u openvswitch-ipsec.service --no-pager --output=short-iso --since "60 min ago"' \ + > "${ARTIFACT_DIR}/openvswitch-ipsec-node${idx}.log" 2>/dev/null + # ovn-controller and ovs-vswitchd log to files, never to journald: + # without these a cross-chassis black-hole cannot be attributed to + # IPsec versus SB port binding. + mkdir -p "${ARTIFACT_DIR}/ovs-logs-node${idx}" + ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo tar -C /var/log -cf - ovn openvswitch 2>/dev/null || true' \ + | tar -C "${ARTIFACT_DIR}/ovs-logs-node${idx}" -xf - 2>/dev/null done cat "${ARTIFACT_DIR}"/spinifex-journal-node*.log \ diff --git a/.golangci.yml b/.golangci.yml index 68623143d..ad42438fe 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,7 +26,7 @@ linters: - errorlint # catches ==/!= on errors, type assertions on errors, and %v instead of %w — all break on wrapped errors - exptostd # detects golang.org/x/exp/ functions replaceable by stdlib - fatcontext # detects nested contexts in loops - - forbidigo # blocks the legacy JetStream/KV symbols + - forbidigo # blocks forbidden identifiers - gocheckcompilerdirectives # validates go compiler directive comments (//go:) - godot # checks that declaration comments end in a period - goprintffuncname # checks that printf-like functions are named with f at the end @@ -92,6 +92,8 @@ linters: forbidigo: analyze-types: true forbid: + - pattern: '^panic$' + msg: panic terminates the process; return an error or explicitly justify the panic. - pattern: '\.JetStream$' pkg: '^github\.com/nats-io/nats\.go$' msg: legacy nc.JetStream() is superseded; construct with jetstream.New(nc). @@ -166,6 +168,7 @@ linters: - bodyclose - errcheck - errchkjson + - forbidigo - gosec # spinifex-specific: gosec exclusions diff --git a/build/systemd/spinifex-predastore.service b/build/systemd/spinifex-predastore.service index f589ed153..c6a334a06 100644 --- a/build/systemd/spinifex-predastore.service +++ b/build/systemd/spinifex-predastore.service @@ -1,4 +1,4 @@ -# spinifex-unit-version: 2 +# spinifex-unit-version: 3 [Unit] Description=Spinifex Predastore (S3-compatible Object Storage) PartOf=spinifex.target @@ -7,9 +7,9 @@ Wants=spinifex-nats.service [Service] EnvironmentFile=-/etc/spinifex/telemetry.env -# Guest block I/O arrives with no caller trace; sample those roots at 5% so -# they don't drown request traces. Request-linked spans are always kept. -Environment=MULGA_ROOT_TRACE_RATIO=0.05 +# No MULGA_ROOT_TRACE_RATIO here: the sink's tail sampler sheds guest block I/O +# instead, and it can see whether a trace errored. A head ratio decides before +# the error exists, so it discarded failures at the same rate as successes. Type=simple Slice=spinifex-system.slice User=spinifex-storage diff --git a/cmd/rds-agent/agent_test.go b/cmd/rds-agent/agent_test.go index 7b9f2944b..410bd74db 100644 --- a/cmd/rds-agent/agent_test.go +++ b/cmd/rds-agent/agent_test.go @@ -207,6 +207,8 @@ func bootstrapOutput(password *string) *handlers_rds.GetDBBootstrapConfigOutput MasterUsername: "master", MasterUserPassword: password, Port: 5432, + ListenAddress: testListenAddress, + ClientCIDR: testClientCIDR, DataVolumeID: "vol-data-01", DataVolumeSerial: "voldata01", VMGeneration: 1, @@ -217,6 +219,13 @@ func bootstrapOutput(password *string) *handlers_rds.GetDBBootstrapConfigOutput const testPayloadID = "bp-0123456789abcdef" +// The customer ENI address the engine binds, and the VPC range its client +// authentication admits. writeHandoff refuses a config without either. +const ( + testListenAddress = "10.0.1.20" + testClientCIDR = "10.0.0.0/16" +) + // Writes the completion receipt rds-init leaves behind, so the acknowledgement // step finds what a finished initialization would have produced. func writeReceipt(t *testing.T, a *Agent, payloadID, dbInstanceIdentifier string) { diff --git a/cmd/rds-agent/bootstrap.go b/cmd/rds-agent/bootstrap.go index 2502768de..d2368741e 100644 --- a/cmd/rds-agent/bootstrap.go +++ b/cmd/rds-agent/bootstrap.go @@ -96,6 +96,15 @@ func writeHandoff(dir string, cfg *handlers_rds.GetDBBootstrapConfigOutput) erro if cfg.MasterUsername == "" { return fmt.Errorf("bootstrap config carries no master username") } + // Half a handoff would leave rds-init deciding what to bind and who to admit, + // and the only answers it could reach on its own are the wildcards this + // contract exists to remove. + if cfg.ListenAddress == "" { + return fmt.Errorf("bootstrap config carries no listen address for the engine to bind") + } + if cfg.ClientCIDR == "" { + return fmt.Errorf("bootstrap config carries no client CIDR to scope client authentication to") + } if err := os.MkdirAll(dir, handoffDirMode); err != nil { return fmt.Errorf("create handoff dir %s: %w", dir, err) } @@ -134,6 +143,8 @@ func renderBootstrapEnv(cfg *handlers_rds.GetDBBootstrapConfigOutput) string { writeEnvLine(&b, "RDS_MODE", cfg.Mode) writeEnvLine(&b, "RDS_DB_INSTANCE_IDENTIFIER", cfg.DBInstanceIdentifier) writeEnvLine(&b, "RDS_MASTER_USERNAME", cfg.MasterUsername) + writeEnvLine(&b, "RDS_LISTEN_ADDRESS", cfg.ListenAddress) + writeEnvLine(&b, "RDS_CLIENT_CIDR", cfg.ClientCIDR) writeEnvLine(&b, "RDS_DATA_VOLUME_ID", cfg.DataVolumeID) writeEnvLine(&b, "RDS_DATA_VOLUME_SERIAL", cfg.DataVolumeSerial) writeEnvLine(&b, "RDS_VM_GENERATION", strconv.FormatInt(cfg.VMGeneration, 10)) diff --git a/cmd/rds-agent/bootstrap_test.go b/cmd/rds-agent/bootstrap_test.go index 523e6f11c..6544700a3 100644 --- a/cmd/rds-agent/bootstrap_test.go +++ b/cmd/rds-agent/bootstrap_test.go @@ -18,6 +18,8 @@ func TestWriteHandoff_WritesEveryFileRootOnly(t *testing.T) { Mode: handlers_rds.BootstrapModeInitialize, Engine: "postgres", MasterUsername: "master", + ListenAddress: "10.0.1.20", + ClientCIDR: "10.0.0.0/16", MasterUserPassword: &password, DBName: "appdb", Port: 5433, @@ -85,6 +87,8 @@ func TestWriteHandoff_AttachOmitsPassword(t *testing.T) { Mode: handlers_rds.BootstrapModeAttach, Engine: "postgres", MasterUsername: "master", + ListenAddress: "10.0.1.20", + ClientCIDR: "10.0.0.0/16", Port: 5432, }); err != nil { t.Fatalf("writeHandoff: %v", err) @@ -116,6 +120,8 @@ func TestWriteHandoff_QuotesShellMetacharacters(t *testing.T) { Mode: handlers_rds.BootstrapModeInitialize, Engine: "postgres", MasterUsername: "master", + ListenAddress: "10.0.1.20", + ClientCIDR: "10.0.0.0/16", MasterUserPassword: &password, Port: 5432, }); err != nil { @@ -137,6 +143,8 @@ func TestWriteHandoff_SkipsHalfATLSPair(t *testing.T) { Mode: handlers_rds.BootstrapModeAttach, Engine: "postgres", MasterUsername: "master", + ListenAddress: "10.0.1.20", + ClientCIDR: "10.0.0.0/16", Port: 5432, ServingCertificate: "CERT", }); err != nil { @@ -177,6 +185,8 @@ func TestWriteHandoff_TightensExistingDirectory(t *testing.T) { Mode: handlers_rds.BootstrapModeAttach, Engine: "postgres", MasterUsername: "master", + ListenAddress: "10.0.1.20", + ClientCIDR: "10.0.0.0/16", Port: 5432, }); err != nil { t.Fatalf("writeHandoff: %v", err) diff --git a/cmd/rds-agent/config.go b/cmd/rds-agent/config.go index 3473cc841..b4cda5c08 100644 --- a/cmd/rds-agent/config.go +++ b/cmd/rds-agent/config.go @@ -13,7 +13,6 @@ const ( defaultEnvFile = "/etc/spinifex-rds/agent.env" defaultGatewayCA = "/etc/spinifex-rds/gateway-ca.pem" defaultHandoffDir = "/run/spinifex-rds" - defaultEngineHost = "127.0.0.1" // Where setup.sh stamps the engine the image bakes. The agent builds its // engine implementation from this file rather than from anything delivered. defaultEngineFile = "/etc/spinifex-rds/engine" @@ -38,7 +37,6 @@ type config struct { // version alone rather than clearing it. EngineVersion string HandoffDir string - EngineHost string EnginePort int PollWait time.Duration @@ -89,7 +87,6 @@ func loadConfig(envFile string) config { EngineFile: get("RDS_ENGINE_FILE"), EngineVersion: get("RDS_ENGINE_VERSION"), HandoffDir: get("RDS_HANDOFF_DIR"), - EngineHost: get("RDS_ENGINE_HOST"), EngineBinDir: get("RDS_ENGINE_BIN"), EngineDataDir: get("RDS_ENGINE_DATA"), SocketDir: get("RDS_SOCKET_DIR"), @@ -108,9 +105,6 @@ func loadConfig(envFile string) config { if cfg.HandoffDir == "" { cfg.HandoffDir = defaultHandoffDir } - if cfg.EngineHost == "" { - cfg.EngineHost = defaultEngineHost - } if cfg.EngineFile == "" { cfg.EngineFile = defaultEngineFile } diff --git a/cmd/rds-agent/config_test.go b/cmd/rds-agent/config_test.go index be43f70dc..e6414e0d8 100644 --- a/cmd/rds-agent/config_test.go +++ b/cmd/rds-agent/config_test.go @@ -26,8 +26,8 @@ func TestLoadConfig_Defaults(t *testing.T) { if cfg.GatewayCA != defaultGatewayCA || cfg.HandoffDir != defaultHandoffDir { t.Errorf("CA/handoff = %q/%q, want the built-in defaults", cfg.GatewayCA, cfg.HandoffDir) } - if cfg.EngineHost != defaultEngineHost || cfg.PollWait != defaultPollWait { - t.Errorf("engine host/pollWait = %q/%v, want the built-in defaults", cfg.EngineHost, cfg.PollWait) + if cfg.PollWait != defaultPollWait { + t.Errorf("pollWait = %v, want the built-in default", cfg.PollWait) } if cfg.EngineProbeTimeout != defaultEngineProbeTimeout { t.Errorf("engine probe timeout = %v, want %v", cfg.EngineProbeTimeout, defaultEngineProbeTimeout) diff --git a/cmd/rds-agent/engine.go b/cmd/rds-agent/engine.go index 42f8c2ac6..c56b494c5 100644 --- a/cmd/rds-agent/engine.go +++ b/cmd/rds-agent/engine.go @@ -108,9 +108,7 @@ var engineLayouts = map[string]engineLayout{ dataMount: "/var/lib/postgresql", port: 5432, newProbe: newPostgresProbe, - newEngine: func(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) { - return newPostgresEngine(cfg, run, startSess, probe), nil - }, + newEngine: newPostgresEngineFromCatalog, }, engineMariaDB: { binDir: "/usr/bin", diff --git a/cmd/rds-agent/engine_mariadb_test.go b/cmd/rds-agent/engine_mariadb_test.go index 96f79b399..d99c2a5e9 100644 --- a/cmd/rds-agent/engine_mariadb_test.go +++ b/cmd/rds-agent/engine_mariadb_test.go @@ -129,10 +129,11 @@ func newScriptedTestMariaDBEngine(t *testing.T, run commandRunner) *mariadbEngin // that is registered: what a spec says about a parameter is what the guest must // see, and an unlisted name must read as static rather than as dynamic. func TestControlPlaneRulesFrom_TakesTheClassificationFromTheCatalog(t *testing.T) { - rules := controlPlaneRulesFrom(postgresEngineMeta) + meta := testPostgresEngineMeta(t) + rules := controlPlaneRulesFrom(meta) for name, want := range map[string]bool{"shared_buffers": true, "work_mem": false} { - spec, ok := postgresEngineMeta.LookupParameter(name) + spec, ok := meta.LookupParameter(name) if !ok { t.Fatalf("the catalog no longer carries %s", name) } diff --git a/cmd/rds-agent/engine_postgres.go b/cmd/rds-agent/engine_postgres.go index 172d5df5b..c266ea3e1 100644 --- a/cmd/rds-agent/engine_postgres.go +++ b/cmd/rds-agent/engine_postgres.go @@ -41,8 +41,13 @@ var _ engine = (*postgresEngine)(nil) // is not there. Resolved on PATH, where the client package puts it. const postgresProbeBinary = "pg_isready" +// The socket directory rather than loopback: the engine binds the customer ENI +// and nothing else, so there is no TCP path for the probe to take. It also keeps +// liveness independent of the network path, of the pg_hba scope and of the TLS +// enforcement rule — libpq reads a host beginning with / as a socket directory, +// and the generated `local ... peer` line covers it. func newPostgresProbe(cfg config, run probeRunner) *engineProbe { - return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.EngineHost, run)) + return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.SocketDir, run)) } func postgresProbeState(host string, run probeRunner) probeStateFn { @@ -65,19 +70,6 @@ func postgresProbeState(host string, run probeRunner) probeStateFn { } } -// Resolved once, under the name the control plane knows this implementation by. -// A lookup failure is a mismatch between the agent and the control plane it was -// built against, so it fails at startup rather than at the first rotation. -var postgresEngineMeta = mustLookupEngine(enginePostgres) - -func mustLookupEngine(name string) handlers_rds.Engine { - engine, err := handlers_rds.LookupEngine(name) - if err != nil { - panic("rds-agent: " + err.Error()) - } - return engine -} - // The include the resolved set is rendered to, and the copy of the last one the // engine accepted. Both live beside the data rather than in /etc, matching // rds-init: a class change boots a fresh root volume and would revert them. @@ -109,9 +101,18 @@ hostnossl all all ::/0 reject postgresForceSSLRuleCount = 2 ) -func newPostgresEngine(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) *postgresEngine { +// The layout's factory resolves the control plane metadata during startup. +func newPostgresEngineFromCatalog(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) { + meta, err := handlers_rds.LookupEngine(enginePostgres) + if err != nil { + return nil, fmt.Errorf("this image bakes %s, which this build's control plane does not offer: %w", enginePostgres, err) + } + return newPostgresEngine(cfg, meta, run, startSess, probe), nil +} + +func newPostgresEngine(cfg config, meta handlers_rds.Engine, run commandRunner, startSess sessionRunner, probe *engineProbe) *postgresEngine { return &postgresEngine{ - meta: postgresEngineMeta, + meta: meta, run: run, startSess: startSess, psql: filepath.Join(cfg.EngineBinDir, "psql"), diff --git a/cmd/rds-agent/engine_test.go b/cmd/rds-agent/engine_test.go index c74daa54a..07d1d2c36 100644 --- a/cmd/rds-agent/engine_test.go +++ b/cmd/rds-agent/engine_test.go @@ -140,10 +140,19 @@ func (r *recordingRunner) run(_ context.Context, c command) (string, error) { return r.out, r.err } +func testPostgresEngineMeta(t *testing.T) handlers_rds.Engine { + t.Helper() + meta, err := handlers_rds.LookupEngine(enginePostgres) + if err != nil { + t.Fatalf("LookupEngine(%s): %v", enginePostgres, err) + } + return meta +} + func newTestEngine(t *testing.T, run commandRunner) *postgresEngine { t.Helper() cfg := testEngineConfig(t) - return newPostgresEngine(cfg, withPostgresReadBacks(cfg.EngineDataDir, run), nil, newPostgresProbe(cfg, staticProbe(0))) + return newPostgresEngine(cfg, testPostgresEngineMeta(t), withPostgresReadBacks(cfg.EngineDataDir, run), nil, newPostgresProbe(cfg, staticProbe(0))) } // The same engine with the read-backs left to the runner, for the cases that are @@ -152,7 +161,7 @@ func newTestEngine(t *testing.T, run commandRunner) *postgresEngine { func newScriptedTestEngine(t *testing.T, run commandRunner) *postgresEngine { t.Helper() cfg := testEngineConfig(t) - return newPostgresEngine(cfg, run, nil, newPostgresProbe(cfg, staticProbe(0))) + return newPostgresEngine(cfg, testPostgresEngineMeta(t), run, nil, newPostgresProbe(cfg, staticProbe(0))) } func testEngineConfig(t *testing.T) config { @@ -479,10 +488,11 @@ func (r *tlsReadBackRunner) run(ctx context.Context, c command) (string, error) return r.recordingRunner.run(ctx, c) } -func forceSSLParameter(value string) []handlers_rds.Parameter { +func forceSSLParameter(t *testing.T, value string) []handlers_rds.Parameter { + t.Helper() return []handlers_rds.Parameter{ {Name: "work_mem", Value: "4096"}, - {Name: postgresEngineMeta.TLSEnforcementParameter(), Value: value}, + {Name: testPostgresEngineMeta(t).TLSEnforcementParameter(), Value: value}, } } @@ -492,7 +502,7 @@ func TestPostgresEngine_ApplyParametersWritesTheEnforcementRule(t *testing.T) { runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount} engine := newScriptedTestEngine(t, runner.run) - if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("1")); err != nil { + if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil { t.Fatalf("ApplyParameters: %v", err) } rule, err := os.ReadFile(engine.forceSSLRulePath()) @@ -516,7 +526,7 @@ func TestPostgresEngine_ApplyParametersRemovesAStaleEnforcementRule(t *testing.T t.Fatalf("write the rule the restored volume carried: %v", err) } - if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("0")); err != nil { + if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err != nil { t.Fatalf("ApplyParameters: %v", err) } if _, err := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(err) { @@ -547,7 +557,7 @@ func TestPostgresEngine_ApplyParametersRefusesAnUnparsableEnforcementValue(t *te runner := &tlsReadBackRunner{ssl: "on"} engine := newScriptedTestEngine(t, runner.run) - _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("yes")) + _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "yes")) if err == nil || !strings.Contains(err.Error(), "neither 1 nor 0") { t.Fatalf("ApplyParameters error = %v, want a refusal naming the unreadable value", err) } @@ -562,7 +572,7 @@ func TestPostgresEngine_ApplyParametersRefusesToEnforceWithoutTLS(t *testing.T) runner := &tlsReadBackRunner{ssl: "off"} engine := newScriptedTestEngine(t, runner.run) - _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("1")) + _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")) if err == nil || !strings.Contains(err.Error(), "serving without TLS") { t.Fatalf("ApplyParameters error = %v, want a refusal naming the engine's own TLS state", err) } @@ -592,7 +602,7 @@ func TestPostgresEngine_ApplyParametersFailsAnUnverifiableEnforcement(t *testing runner := &tlsReadBackRunner{ssl: "on", ruleRows: tc.ruleRows, brokenRows: tc.brokenRows} engine := newScriptedTestEngine(t, runner.run) - _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(tc.value)) + _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, tc.value)) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("ApplyParameters error = %v, want %q", err, tc.want) } @@ -606,13 +616,13 @@ func TestPostgresEngine_ApplyParametersFailsAnUnverifiableEnforcement(t *testing func TestPostgresEngine_ApplyParametersWithdrawsBothFilesTogether(t *testing.T) { runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount} engine := newScriptedTestEngine(t, runner.run) - if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("1")); err != nil { + if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil { t.Fatalf("first ApplyParameters: %v", err) } // Turning enforcement off, verified against a pg_hba that still carries the // rule — so the apply fails after both files have already been replaced. - if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("0")); err == nil { + if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err == nil { t.Fatal("ApplyParameters succeeded against an unverifiable enforcement") } installed, err := os.ReadFile(engine.params.installedPath()) @@ -639,14 +649,14 @@ func TestPostgresEngine_RestoreLastKnownGoodReDerivesEnforcement(t *testing.T) { runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount} engine := newScriptedTestEngine(t, runner.run) - if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("1")); err != nil { + if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil { t.Fatalf("ApplyParameters(enforcing): %v", err) } if err := engine.RecordServingParameters(t.Context()); err != nil { t.Fatalf("RecordServingParameters: %v", err) } runner.ruleRows = 0 - if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("0")); err != nil { + if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err != nil { t.Fatalf("ApplyParameters(not enforcing): %v", err) } if _, err := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(err) { diff --git a/cmd/rds-agent/health_test.go b/cmd/rds-agent/health_test.go index 25c92db1f..d76b129d7 100644 --- a/cmd/rds-agent/health_test.go +++ b/cmd/rds-agent/health_test.go @@ -110,6 +110,37 @@ func TestExecProbeRunner_CapturesTheClientsStderr(t *testing.T) { } } -func testProbeConfig() config { - return config{EngineHost: defaultEngineHost, EnginePort: engineLayouts[enginePostgres].port} +// The engine binds the customer ENI and nothing else, so a loopback probe would +// report a serving engine as absent. libpq reads a host beginning with / as a +// socket directory, which is also what keeps liveness off the network path. +func TestPostgresProbe_ProbesTheUnixSocket(t *testing.T) { + cfg := testProbeConfig() + var probed []string + probe := newPostgresProbe(cfg, func(_ context.Context, name string, args ...string) (int, string, error) { + probed = append([]string{name}, args...) + return 0, "", nil + }) + + if state, _ := probe.state(context.Background()); state != engineServing { + t.Fatalf("probe state = %v, want engineServing", state) + } + host := "" + for i, arg := range probed { + if arg == "-h" && i+1 < len(probed) { + host = probed[i+1] + } + } + if host != cfg.SocketDir { + t.Errorf("probe host = %q, want the socket directory %q", host, cfg.SocketDir) + } + if !strings.HasPrefix(host, "/") { + t.Errorf("probe host = %q, which libpq reads as a TCP host rather than a socket directory", host) + } +} + +func testProbeConfig() config { + return config{ + SocketDir: engineLayouts[enginePostgres].socketDir, + EnginePort: engineLayouts[enginePostgres].port, + } } diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index fb0eb1bd4..5a9ae5d11 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -415,15 +415,11 @@ func init() { imagesRemoveCmd.Flags().String("image-id", "", "AMI ID to remove (required)") imagesRemoveCmd.Flags().Bool("force", false, "Bypass dependency, ownership and config-corrupt checks (salvage mode)") imagesRemoveCmd.Flags().Bool("yes", false, "Skip interactive confirmation prompt") - if err := imagesRemoveCmd.MarkFlagRequired("image-id"); err != nil { - panic(err) - } + _ = imagesRemoveCmd.MarkFlagRequired("image-id") imagesPromoteCmd.Flags().String("image-id", "", "AMI ID to promote to system image (required)") imagesPromoteCmd.Flags().Bool("yes", false, "Skip interactive confirmation prompt") - if err := imagesPromoteCmd.MarkFlagRequired("image-id"); err != nil { - panic(err) - } + _ = imagesPromoteCmd.MarkFlagRequired("image-id") } const bytesPerGiB = 1024 * 1024 * 1024 diff --git a/cmd/spinifex/cmd/admin_acm.go b/cmd/spinifex/cmd/admin_acm.go index 4ace13f00..a2aaa16ba 100644 --- a/cmd/spinifex/cmd/admin_acm.go +++ b/cmd/spinifex/cmd/admin_acm.go @@ -60,16 +60,12 @@ func init() { certCreateTenantCACmd.Flags().StringSlice("domain", nil, "Domain the tenant CA is permitted to sign for (repeatable)") certCreateTenantCACmd.Flags().Bool("regenerate", false, "Replace an existing tenant CA with a fresh root (invalidates every device's trust store — requires confirmation)") certCreateTenantCACmd.Flags().Bool("yes", false, "Skip the --regenerate confirmation prompt") - if err := certCreateTenantCACmd.MarkFlagRequired("domain"); err != nil { - panic(err) - } + _ = certCreateTenantCACmd.MarkFlagRequired("domain") certCmd.AddCommand(certForceRenewCmd) certForceRenewCmd.Flags().String("arn", "", "Certificate ARN to force-renew (required)") certForceRenewCmd.Flags().String("account", "", "Account ID that owns the certificate; defaults to the bootstrap account") - if err := certForceRenewCmd.MarkFlagRequired("arn"); err != nil { - panic(err) - } + _ = certForceRenewCmd.MarkFlagRequired("arn") } func runCertForceRenew(cmd *cobra.Command, _ []string) { diff --git a/cmd/spinifex/cmd/admin_ochre.go b/cmd/spinifex/cmd/admin_ochre.go index 2b6aa8788..eb734e5b4 100644 --- a/cmd/spinifex/cmd/admin_ochre.go +++ b/cmd/spinifex/cmd/admin_ochre.go @@ -1138,15 +1138,11 @@ func init() { c.Flags().String("account-id", "", "12-digit account ID to change (required)") c.Flags().String("model-id", "", "Model ID to change (e.g. meta.llama3-2-1b-instruct-v1:0)") c.Flags().Bool("all-models", false, "Apply to every model in the platform catalog") - if err := c.MarkFlagRequired("account-id"); err != nil { - panic(err) - } + _ = c.MarkFlagRequired("account-id") } adminOchreAccessListCmd.Flags().String("account-id", "", "12-digit account ID to inspect (required)") - if err := adminOchreAccessListCmd.MarkFlagRequired("account-id"); err != nil { - panic(err) - } + _ = adminOchreAccessListCmd.MarkFlagRequired("account-id") } // ochreAccessStore connects to the cluster and returns the grant store along diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index c712aabef..7033f458b 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -904,6 +904,8 @@ Spinifex both stores externally-issued certificates (`import-certificate`) and i Each DB instance is one dedicated system-owned VM running the engine directly, launched from the `spinifex-rds-postgres` or `spinifex-rds-mariadb` AMI, tagged `spinifex:managed-by=rds` and therefore hidden from the customer's EC2 API. The engine is reached over a customer-account ENI injected into a subnet of the DB subnet group, so **the endpoint is private — reachable from inside the VPC only**, from any subnet of it rather than the endpoint ENI's own. `Endpoint.Address` is `{db-instance-identifier}.{account-id}.{region}.rds.{base-domain}` where northstar is configured, and the endpoint ENI's private IP where it is not; the IP is stable across VM replacement either way. +**The endpoint is the only path to the engine.** The DB VM has two other NICs that no customer security group governs — a system NIC in a shared RDS system VPC, which is how the in-guest agent reaches the control plane, and a management NIC on the host's management bridge — and the engine binds neither. It binds the endpoint ENI's address alone, so the port is not open on them at all rather than open and gated. The system NIC additionally carries a dedicated region-wide security group that authorizes no ingress; agent traffic is outbound and its replies are matched by connection state. PostgreSQL's generated `pg_hba.conf` admits the customer VPC's address range and nothing wider, so a client outside the VPC is refused authentication even if it somehow reached the socket. MariaDB has no equivalent file: its analogue is the host pattern on a grant, which lives in the data volume and would follow a restore into another VPC, so for that engine the bind is the whole boundary. + Everything below applies to both engines unless it names one. `Engine` is fixed at create — there is no in-place engine change, no cross-engine snapshot restore and no migration between the two. | | `postgres` | `mariadb` | diff --git a/docs/README.md b/docs/README.md index 6b523c0f1..5d8e6974b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,3 +3,19 @@ This directory contains the Spinifex documentation. This is available in individual markdown files, and a generated website to aid navigation and browsing documentation at: [https://docs.mulgadc.com](https://docs.mulgadc.com/) The [AWS model operation coverage](aws-model-operation-coverage.md) report is generated from Spinifex's dispatch tables and the pinned AWS API models. Run `make aws-model-coverage` to regenerate and print it. + +## Frontmatter + +Each published page lives at `//README.md` and carries YAML frontmatter that the docs site turns into page metadata. Three fields have length rules, because search engines flag a title tag or meta description that falls outside them: + +| Field | Renders as | Length | +| ------------- | ----------------------------------------- | ------------------ | +| `title` | Page heading, sidebar label, landing card | Short — it is UI text | +| `seoTitle` | The browser and search-result title | 50-60 characters | +| `description` | The search-result snippet, not shown on the page | 150-160 characters | + +`seoTitle` is used exactly as written and must end with ` — Spinifex Docs`. Leave it out and the title falls back to `title` plus that suffix, which is normally too short to satisfy the rule. + +Keep the two titles distinct. `title` is navigation text in a narrow sidebar, so stretching it to reach 50 characters damages the layout to satisfy a rule that only applies to the title tag. Put the search-facing wording in `seoTitle`. + +A new page is only published once its path is added to `scripts/docs-config.json` in the `mulga-docs` repo. That repo's `scripts/build-docs.js` warns about any field outside its range. diff --git a/docs/admin/setting-up-your-cluster/README.md b/docs/admin/setting-up-your-cluster/README.md index f95a09d86..1765ad490 100644 --- a/docs/admin/setting-up-your-cluster/README.md +++ b/docs/admin/setting-up-your-cluster/README.md @@ -1,6 +1,7 @@ --- title: "Setting Up Your Cluster" -description: "Import an AMI, create a VPC with a public subnet, and launch your first EC2 instance on Spinifex." +seoTitle: "Set Up Your First Spinifex Cluster — Spinifex Docs" +description: "Import an AMI, create an SSH key pair and a VPC with a public subnet, then launch your first EC2 instance and connect to it on a fresh Spinifex cluster." category: "Admin" tags: - setup diff --git a/docs/admin/spinifex-admin-cli/README.md b/docs/admin/spinifex-admin-cli/README.md index 2ac576eff..30f7afc89 100644 --- a/docs/admin/spinifex-admin-cli/README.md +++ b/docs/admin/spinifex-admin-cli/README.md @@ -1,6 +1,7 @@ --- title: "Spinifex Admin CLI" -description: "Complete reference for the Spinifex administration CLI. Manage accounts, nodes, VMs, and services." +seoTitle: "spx: The Spinifex Administration CLI — Spinifex Docs" +description: "Complete reference for spx, the Spinifex admin CLI: initialise a cluster, manage accounts and nodes, drive the VM lifecycle, and start or stop services." category: "Admin" tags: - cli diff --git a/docs/admin/update/README.md b/docs/admin/update/README.md index fb3fccd23..f3a27c0fc 100644 --- a/docs/admin/update/README.md +++ b/docs/admin/update/README.md @@ -1,6 +1,7 @@ --- title: "Updating Spinifex" -description: "Upgrade an existing Spinifex installation to the latest release." +seoTitle: "Update Spinifex to the Latest Release — Spinifex Docs" +description: "Upgrade an existing Spinifex install with the same installer used to deploy it, or take the manual path to review configuration migrations before applying them." category: "Admin" tags: - update diff --git a/docs/compute/gpu-passthrough/README.md b/docs/compute/gpu-passthrough/README.md index 9a9c78db5..91f5f8f4f 100644 --- a/docs/compute/gpu-passthrough/README.md +++ b/docs/compute/gpu-passthrough/README.md @@ -1,6 +1,7 @@ --- title: "GPU Passthrough" -description: "Configure VFIO GPU passthrough on a Spinifex node to expose NVIDIA or AMD GPUs to EC2 instances." +seoTitle: "VFIO GPU Passthrough for EC2 Instances — Spinifex Docs" +description: "Configure VFIO GPU passthrough on a Spinifex node to bind NVIDIA or AMD GPUs to guest VMs and expose GPU-enabled EC2 instance types to your workloads." category: "Compute" tags: - gpu diff --git a/docs/compute/launching-instances/README.md b/docs/compute/launching-instances/README.md index 55d8649e3..cb35ad4cd 100644 --- a/docs/compute/launching-instances/README.md +++ b/docs/compute/launching-instances/README.md @@ -1,6 +1,7 @@ --- title: "Launching Instances" -description: "Launch, manage, and connect to EC2-compatible virtual machines on Spinifex." +seoTitle: "Launching AWS EC2 Instances on Spinifex — Spinifex Docs" +description: "Launch, manage, and connect to EC2-compatible virtual machines on Spinifex, with cloud-init, SSH key injection, VPC networking, and AWS lifecycle operations." category: "Compute" tags: - ec2 diff --git a/docs/compute/placement-groups/README.md b/docs/compute/placement-groups/README.md index 89e26d722..7608cfb2f 100644 --- a/docs/compute/placement-groups/README.md +++ b/docs/compute/placement-groups/README.md @@ -1,6 +1,7 @@ --- title: "Placement Groups" -description: "Create and manage spread and cluster placement groups for hardware-level instance placement control." +seoTitle: "AWS EC2 Placement Groups on Spinifex — Spinifex Docs" +description: "Create and manage spread and cluster placement groups to control how Spinifex places EC2 instances across physical hosts for fault isolation or low latency." category: "Compute" tags: - ec2 diff --git a/docs/compute/vpc-networking/README.md b/docs/compute/vpc-networking/README.md index a017e627e..fa709018a 100644 --- a/docs/compute/vpc-networking/README.md +++ b/docs/compute/vpc-networking/README.md @@ -1,6 +1,7 @@ --- title: "VPC Networking" -description: "How Spinifex implements AWS-compatible VPC networking with public and private subnets, security groups, and Elastic IPs using OVN." +seoTitle: "AWS-Compatible VPC Networking with OVN — Spinifex Docs" +description: "How Spinifex implements AWS-compatible VPC networking on bare metal with OVN: public and private subnets, security groups, route tables, and Elastic IPs." category: "Compute" tags: - vpc diff --git a/docs/containers/ecr/README.md b/docs/containers/ecr/README.md index 3386b816e..5eb77b2dc 100644 --- a/docs/containers/ecr/README.md +++ b/docs/containers/ecr/README.md @@ -1,6 +1,7 @@ --- title: "ECR (Container Registry)" -description: "Store and serve container images from Spinifex's AWS-compatible Elastic Container Registry — create a repository, authenticate Docker, push and pull images, and let your EKS workers pull from it, using the AWS CLI, the Spinifex console, or Terraform." +seoTitle: "AWS ECR Container Registry on Spinifex — Spinifex Docs" +description: "Store and serve container images from Spinifex's AWS-compatible ECR: create a repository, authenticate Docker, push and pull images, and let EKS workers pull." category: "Containers" tags: - ecr diff --git a/docs/containers/ecs/README.md b/docs/containers/ecs/README.md index d29d9ab20..f1869d875 100644 --- a/docs/containers/ecs/README.md +++ b/docs/containers/ecs/README.md @@ -1,6 +1,7 @@ --- title: "ECS (Elastic Container Service)" -description: "Run AWS-compatible ECS workloads on Spinifex — create a cluster, register a task definition, boot container instances from the ECS node image, run tasks, and front a long-running service with an Application Load Balancer, all via the AWS CLI, the Spinifex console, or Terraform." +seoTitle: "AWS ECS Elastic Container Service Guide — Spinifex Docs" +description: "Create an ECS cluster on Spinifex, register a task definition, boot container instances, run tasks, and front a service with an Application Load Balancer." category: "Containers" tags: - ecs diff --git a/docs/containers/eks/README.md b/docs/containers/eks/README.md index b9f6f12e5..5d6cd14c9 100644 --- a/docs/containers/eks/README.md +++ b/docs/containers/eks/README.md @@ -1,6 +1,7 @@ --- title: "EKS (Managed Kubernetes)" -description: "Run a managed, AWS-compatible Kubernetes cluster on Spinifex — provision the control plane and worker node group, wire up the VPC, IAM, and security groups it needs, and deploy your first workload via the AWS CLI, the Spinifex console, or Terraform." +seoTitle: "AWS EKS Managed Kubernetes on Spinifex — Spinifex Docs" +description: "Provision an AWS-compatible EKS control plane and managed node group on Spinifex, wire up the VPC, IAM, and security groups, then deploy your first workload." category: "Containers" tags: - eks diff --git a/docs/install/install-airgapped/README.md b/docs/install/install-airgapped/README.md index 04ae03868..67c60863f 100644 --- a/docs/install/install-airgapped/README.md +++ b/docs/install/install-airgapped/README.md @@ -1,5 +1,6 @@ --- title: "Air-Gapped Install" +seoTitle: "Install Spinifex in an Air-Gapped Network — Spinifex Docs" description: "Deploy Spinifex in environments without internet connectivity. Covers using a pre-built release tarball, mirrored APT packages, and locally-staged cloud images." category: "Install" tags: diff --git a/docs/install/install-multi-node/README.md b/docs/install/install-multi-node/README.md index c8a9de95e..6db6c15c2 100644 --- a/docs/install/install-multi-node/README.md +++ b/docs/install/install-multi-node/README.md @@ -1,6 +1,7 @@ --- title: "Multi-Node Install" -description: "Deploy Spinifex across multiple servers to create an availability zone with high availability, data durability, and fault tolerance." +seoTitle: "Multi-Node Spinifex Cluster Install — Spinifex Docs" +description: "Deploy Spinifex across several servers to form an availability zone with automatic cluster formation, high availability, data durability, and fault tolerance." category: "Install" tags: - install diff --git a/docs/install/install-source/README.md b/docs/install/install-source/README.md index 4422c0867..de4c20e2a 100644 --- a/docs/install/install-source/README.md +++ b/docs/install/install-source/README.md @@ -1,6 +1,7 @@ --- title: "Source Install" -description: "Build Spinifex from source for development, custom builds, or contributing." +seoTitle: "Build and Install Spinifex from Source — Spinifex Docs" +description: "Build Spinifex from source on Ubuntu or Debian for development, custom builds, or contributing changes, then install and run the resulting binaries locally." category: "Install" tags: - install diff --git a/docs/install/install-usb/README.md b/docs/install/install-usb/README.md index 929275091..afc0f9dad 100644 --- a/docs/install/install-usb/README.md +++ b/docs/install/install-usb/README.md @@ -1,6 +1,7 @@ --- title: "Bootable USB Install" -description: "Install Spinifex on bare-metal hardware by flashing the Spinifex ISO to a USB drive and booting the target device from it." +seoTitle: "Install Spinifex from a Bootable USB — Spinifex Docs" +description: "Install Spinifex on bare-metal x86 hardware by flashing the Spinifex ISO to a USB drive, booting the target server from it, and wiping the disk you select." category: "Install" sections: - overview diff --git a/docs/install/install/README.md b/docs/install/install/README.md index 479bebc3b..c96fc080a 100644 --- a/docs/install/install/README.md +++ b/docs/install/install/README.md @@ -1,6 +1,7 @@ --- title: "Single-Node Install" -description: "Install Spinifex on a single server using the binary installer." +seoTitle: "Install Spinifex on a Single Server — Spinifex Docs" +description: "Install Spinifex on one Ubuntu or Debian server with the binary installer and get an AWS-compatible EC2, S3, and VPC stack running on your own hardware." category: "Install" tags: - install diff --git a/docs/migration/hybrid-sync/README.md b/docs/migration/hybrid-sync/README.md index 26522bcba..d80d78745 100644 --- a/docs/migration/hybrid-sync/README.md +++ b/docs/migration/hybrid-sync/README.md @@ -1,6 +1,7 @@ --- title: "Hybrid Sync" -description: "Synchronize data between Spinifex and AWS when network connectivity is available." +seoTitle: "Hybrid Sync Between Spinifex and AWS — Spinifex Docs" +description: "Synchronise data bidirectionally between Spinifex and AWS when connectivity allows, so local infrastructure stays usable at intermittently connected sites." category: "Migration" tags: - hybrid diff --git a/docs/migration/moving-aws-workload/README.md b/docs/migration/moving-aws-workload/README.md index 55df898a5..7d3f1f581 100644 --- a/docs/migration/moving-aws-workload/README.md +++ b/docs/migration/moving-aws-workload/README.md @@ -1,6 +1,7 @@ --- title: "Moving an AWS Workload to Mulga" -description: "Migrate existing AWS workloads to Spinifex using compatible APIs, SDKs, and Terraform." +seoTitle: "Migrate an AWS Workload to Spinifex — Spinifex Docs" +description: "Move existing AWS workloads onto Spinifex using compatible APIs, SDKs, and Terraform across EC2, VPC, EBS, S3, IAM, STS, ELBv2, ACM, ECR, ECS, and EKS." category: "Migration" tags: - migration diff --git a/docs/reference-architectures/OnLogic-HX401-single-node/README.md b/docs/reference-architectures/OnLogic-HX401-single-node/README.md index fa262325e..159780c21 100644 --- a/docs/reference-architectures/OnLogic-HX401-single-node/README.md +++ b/docs/reference-architectures/OnLogic-HX401-single-node/README.md @@ -1,6 +1,7 @@ --- title: "Single-Node Spinifex on OnLogic HX401" -description: "Run a complete EC2, EBS and S3 stack on one fanless OnLogic edge node, and measure what the platform costs against bare metal." +seoTitle: "Single-Node Spinifex on OnLogic HX401 — Spinifex Docs" +description: "Run a complete EC2, EBS, and S3 stack on one fanless OnLogic HX401 edge node, and measure what the platform layer costs compared with bare-metal baselines." category: "Reference Architectures" tags: - onlogic diff --git a/docs/reference-architectures/RTX-Pro-6000-EKS/README.md b/docs/reference-architectures/RTX-Pro-6000-EKS/README.md index 33a560205..50c1b8627 100644 --- a/docs/reference-architectures/RTX-Pro-6000-EKS/README.md +++ b/docs/reference-architectures/RTX-Pro-6000-EKS/README.md @@ -1,6 +1,7 @@ --- title: "Spinifex EKS AI Platform on Dual RTX Pro 6000 Baremetal" -description: "Deploy a GPU-accelerated AI inference platform — an OpenAI-compatible LLM API and a real-time CV stream — in Kubernetes on bare-metal hardware, managed entirely with standard AWS tooling." +seoTitle: "EKS AI Platform on Dual RTX Pro 6000 — Spinifex Docs" +description: "Deploy a GPU-accelerated AI inference platform — an OpenAI-compatible LLM API and a real-time CV stream — on Kubernetes on bare metal with standard AWS tooling." category: "Reference Architectures" tags: - nvidia diff --git a/docs/reference-architectures/SMCI-H14-orchestration/README.md b/docs/reference-architectures/SMCI-H14-orchestration/README.md index 00302c011..e91781a49 100644 --- a/docs/reference-architectures/SMCI-H14-orchestration/README.md +++ b/docs/reference-architectures/SMCI-H14-orchestration/README.md @@ -1,6 +1,7 @@ --- title: "Multi-Tenant AI on Supermicro H14 with AMD MI350X" -description: "Provision isolated GPU VMs for simultaneous AI workloads on a Supermicro H14 bare-metal node using Spinifex's EC2-compatible API." +seoTitle: "Multi-Tenant AI on Supermicro H14 MI350X — Spinifex Docs" +description: "Provision isolated GPU VMs for simultaneous AI workloads on a Supermicro H14 bare-metal node with AMD MI350X GPUs, using Spinifex's EC2-compatible API." category: "Reference Architectures" tags: - supermicro diff --git a/docs/reference-architectures/SMCI-MIG-orchestration/README.md b/docs/reference-architectures/SMCI-MIG-orchestration/README.md index 9f75b69e2..15ab0a00f 100644 --- a/docs/reference-architectures/SMCI-MIG-orchestration/README.md +++ b/docs/reference-architectures/SMCI-MIG-orchestration/README.md @@ -1,6 +1,7 @@ --- title: "Mixed AI Workloads on a Single H200 Chassis: Guest-Managed MIG" -description: "Install Spinifex from source, configure host-local networking, attach Predastore storage, and run four concurrent AI workloads across MIG partitions managed inside guest VMs." +seoTitle: "Guest-Managed MIG on an H200 Chassis — Spinifex Docs" +description: "Install Spinifex from source, configure host-local networking, attach Predastore storage, and run four concurrent AI workloads across guest-managed MIG slices." category: "Reference Architectures" tags: - nvidia diff --git a/docs/reference-architectures/arcee-trinity-mi350x/README.md b/docs/reference-architectures/arcee-trinity-mi350x/README.md index 02eec638d..27c7498f2 100644 --- a/docs/reference-architectures/arcee-trinity-mi350x/README.md +++ b/docs/reference-architectures/arcee-trinity-mi350x/README.md @@ -1,6 +1,7 @@ --- title: "Arcee Trinity 400B FP8 on AMD MI350X with Spinifex" -description: "Provision, load, and benchmark arcee-ai/Trinity-Large-Preview-FP8 across two AMD MI350X GPUs on a Supermicro H14 using Spinifex's EC2-compatible API." +seoTitle: "Arcee Trinity 400B FP8 on AMD MI350X — Spinifex Docs" +description: "Provision, load, and benchmark arcee-ai/Trinity-Large-Preview-FP8 across two AMD MI350X GPUs on a Supermicro H14 node using Spinifex's EC2-compatible API." category: "Reference Architectures" tags: - supermicro diff --git a/docs/reference-architectures/cisco-ucs-llm-serving/README.md b/docs/reference-architectures/cisco-ucs-llm-serving/README.md index a01394fba..90cccfff1 100644 --- a/docs/reference-architectures/cisco-ucs-llm-serving/README.md +++ b/docs/reference-architectures/cisco-ucs-llm-serving/README.md @@ -1,6 +1,7 @@ --- title: "vLLM Serving on Cisco UCS: Intel AMX vs NVIDIA L4" -description: "Qwen2.5-7B-Instruct served with vLLM on the same Cisco UCS Spinifex cluster, comparing Intel AMX-accelerated CPU serving against NVIDIA L4 GPU serving across input length and concurrency." +seoTitle: "vLLM on Cisco UCS: Intel AMX vs NVIDIA L4 — Spinifex Docs" +description: "Qwen2.5-7B-Instruct served with vLLM on a Cisco UCS Spinifex cluster, comparing Intel AMX-accelerated CPU serving with NVIDIA L4 GPU serving under concurrency." category: "Reference Architectures" tags: - cisco diff --git a/docs/reference-architectures/cisco-ucs-platform-benchmark/README.md b/docs/reference-architectures/cisco-ucs-platform-benchmark/README.md index e370f5e53..1dca6133e 100644 --- a/docs/reference-architectures/cisco-ucs-platform-benchmark/README.md +++ b/docs/reference-architectures/cisco-ucs-platform-benchmark/README.md @@ -1,6 +1,7 @@ --- title: "Spinifex Platform Benchmark on Cisco UCS (3-Node)" -description: "Measured CPU, disk, network, and S3 performance of Spinifex on a 3-node Cisco Unified Edge cluster — host versus guest, Intel AMX confirmed executing at the ISA level, and EC2 instance worker concurrency limits." +seoTitle: "Spinifex Benchmark on 3-Node Cisco UCS — Spinifex Docs" +description: "Measured CPU, disk, network, and S3 performance of Spinifex on a 3-node Cisco Unified Edge cluster, comparing host against guest with Intel AMX confirmed." category: "Reference Architectures" tags: - cisco diff --git a/docs/reference-architectures/cisco-ucs-vision-pipeline/README.md b/docs/reference-architectures/cisco-ucs-vision-pipeline/README.md index dc3f6b983..16ab4f355 100644 --- a/docs/reference-architectures/cisco-ucs-vision-pipeline/README.md +++ b/docs/reference-architectures/cisco-ucs-vision-pipeline/README.md @@ -1,6 +1,7 @@ --- title: "Spinifex Vision Pipeline on Cisco UCS" -description: "A YOLO11m object-detection + Qwen2-VL scene-captioning pipeline on Spinifex, streaming from a shared Predastore bucket across two independent EC2-compatible instances, with Intel AMX and NVIDIA L4 acceleration compared head-to-head." +seoTitle: "Spinifex Vision Pipeline on Cisco UCS — Spinifex Docs" +description: "A YOLO11m detection and Qwen2-VL captioning pipeline streaming from a shared Predastore bucket across two EC2 instances, with Intel AMX and NVIDIA L4 compared." category: "Reference Architectures" tags: - cisco diff --git a/docs/security/flaw-remediation-policy/README.md b/docs/security/flaw-remediation-policy/README.md index 0ee68cc35..f39e1ff5f 100644 --- a/docs/security/flaw-remediation-policy/README.md +++ b/docs/security/flaw-remediation-policy/README.md @@ -1,6 +1,7 @@ --- title: "Flaw Remediation Policy" -description: "CVSS-tiered SLAs for identifying, reporting, and correcting software flaws in Spinifex" +seoTitle: "Spinifex Flaw Remediation Policy (CVSS) — Spinifex Docs" +description: "CVSS-tiered SLAs for identifying, reporting, and correcting software flaws in Spinifex and its direct dependencies, for maintainers and CMMC Level 1 operators." category: "Security" sections: - overview diff --git a/docs/security/malware-protection/README.md b/docs/security/malware-protection/README.md index 71479da0b..131a7b646 100644 --- a/docs/security/malware-protection/README.md +++ b/docs/security/malware-protection/README.md @@ -1,6 +1,7 @@ --- title: "Malware Protection" -description: "Operator guide for host-based malware protection and file integrity monitoring on Spinifex nodes" +seoTitle: "Malware Protection for Spinifex Nodes — Spinifex Docs" +description: "Operator guide to host-based malware protection and file integrity monitoring on the Linux hosts running Spinifex services, aligned to CMMC Level 1 needs." category: "Security" sections: - overview diff --git a/docs/security/media-sanitization/README.md b/docs/security/media-sanitization/README.md index 0666d1bb5..971c371cc 100644 --- a/docs/security/media-sanitization/README.md +++ b/docs/security/media-sanitization/README.md @@ -1,6 +1,7 @@ --- title: "Media Sanitization and Disposal" -description: "Operator guide for sanitizing and disposing of storage media used by Spinifex nodes" +seoTitle: "Media Sanitization and Disposal Guide — Spinifex Docs" +description: "Operator guide to sanitizing and disposing of storage media used by Spinifex nodes, covering system disks, Viperblock and Predastore volumes, and key tokens." category: "Security" sections: - overview diff --git a/docs/security/network-connections/README.md b/docs/security/network-connections/README.md index 74dab41bf..c5d0803ba 100644 --- a/docs/security/network-connections/README.md +++ b/docs/security/network-connections/README.md @@ -1,6 +1,7 @@ --- title: "External Connection Inventory" -description: "Operator inventory of inbound listeners and outbound connections on Spinifex nodes" +seoTitle: "Spinifex External Connection Inventory — Spinifex Docs" +description: "Operator inventory of every inbound listener and outbound connection on Spinifex nodes, with ports, protocols, and purpose, for documented CMMC Level 1 sites." category: "Security" sections: - overview diff --git a/docs/security/physical-security-guide/README.md b/docs/security/physical-security-guide/README.md index cc53afa90..8abcabe50 100644 --- a/docs/security/physical-security-guide/README.md +++ b/docs/security/physical-security-guide/README.md @@ -1,6 +1,7 @@ --- title: "Physical Security Operator Guide" -description: "Operator guide for physical access controls, visitor handling, access logging, and access-device management at sites hosting Spinifex nodes" +seoTitle: "Physical Security Guide for Operators — Spinifex Docs" +description: "Operator guide to physical access controls, visitor handling, access logging, and access-device management at sites hosting Spinifex nodes and network gear." category: "Security" sections: - overview diff --git a/docs/terraform-workbooks/bastion-private-subnet/README.md b/docs/terraform-workbooks/bastion-private-subnet/README.md index 516d773e6..6c50529c0 100644 --- a/docs/terraform-workbooks/bastion-private-subnet/README.md +++ b/docs/terraform-workbooks/bastion-private-subnet/README.md @@ -1,6 +1,7 @@ --- title: "Bastion with Private Subnet" -description: "Deploy a bastion host to access an isolated private compute instance with no internet connectivity." +seoTitle: "Terraform Bastion Host and Private Subnet — Spinifex Docs" +description: "Deploy a VPC with public and private subnets on Spinifex, then use a bastion host as the only route to an isolated EC2 instance that has no internet access." category: "Terraform Workbooks" tags: - terraform diff --git a/docs/terraform-workbooks/ecs-quickstart/README.md b/docs/terraform-workbooks/ecs-quickstart/README.md index 8aeb75019..9e89d41f9 100644 --- a/docs/terraform-workbooks/ecs-quickstart/README.md +++ b/docs/terraform-workbooks/ecs-quickstart/README.md @@ -1,6 +1,7 @@ --- title: "ECS Quickstart" -description: "Stand up a complete AWS-compatible ECS stack on Spinifex with Terraform — a VPC, IAM roles, a cluster, a task definition with a task role, container instances launched from the ECS node image, and an awsvpc service fronted by an Application Load Balancer." +seoTitle: "Terraform ECS Quickstart on Spinifex — Spinifex Docs" +description: "Stand up a full AWS-compatible ECS stack with Terraform: a VPC, IAM roles, a cluster, a task definition, container instances, and a load-balanced service." category: "Terraform Workbooks" tags: - terraform diff --git a/docs/terraform-workbooks/eks-gitops-argocd/README.md b/docs/terraform-workbooks/eks-gitops-argocd/README.md index 03cb06873..2f89e425a 100644 --- a/docs/terraform-workbooks/eks-gitops-argocd/README.md +++ b/docs/terraform-workbooks/eks-gitops-argocd/README.md @@ -1,6 +1,7 @@ --- title: "GitOps on EKS (Argo CD + EBS-CSI)" -description: "Deliver a Spinifex-themed app to EKS with GitOps — the Argo CD addon syncs it from a git repo, an EBS-CSI (Viperblock) PersistentVolume holds its state, and it is served over HTTPS through the AWS Load Balancer Controller + ACM, using Terraform on Spinifex." +seoTitle: "GitOps on EKS with Argo CD and EBS-CSI — Spinifex Docs" +description: "Deliver an app to EKS with GitOps: the Argo CD addon syncs it from git, an EBS-CSI PersistentVolume holds its state, and HTTPS is served via LBC and ACM." category: "Terraform Workbooks" tags: - terraform diff --git a/docs/terraform-workbooks/eks-https-ingress/README.md b/docs/terraform-workbooks/eks-https-ingress/README.md index 8ebea2d93..1a9a9096d 100644 --- a/docs/terraform-workbooks/eks-https-ingress/README.md +++ b/docs/terraform-workbooks/eks-https-ingress/README.md @@ -1,6 +1,7 @@ --- title: "EKS HTTPS Ingress (LBC + ACM)" -description: "Serve a Spinifex-themed demo app over HTTPS on EKS using the AWS Load Balancer Controller addon and an ACM certificate — an internet-facing ALB provisioned from a Kubernetes Ingress, using Terraform on Spinifex." +seoTitle: "EKS HTTPS Ingress with LBC and ACM — Spinifex Docs" +description: "Serve a demo app over HTTPS on EKS using the AWS Load Balancer Controller addon and an ACM certificate, with an internet-facing ALB built from an Ingress." category: "Terraform Workbooks" tags: - terraform diff --git a/docs/terraform-workbooks/eks-quickstart/README.md b/docs/terraform-workbooks/eks-quickstart/README.md index dd8dc4d4d..37b937f6f 100644 --- a/docs/terraform-workbooks/eks-quickstart/README.md +++ b/docs/terraform-workbooks/eks-quickstart/README.md @@ -1,6 +1,7 @@ --- title: "EKS Quickstart" -description: "Stand up a minimal managed-Kubernetes cluster and a browsable Spinifex-themed demo app — VPC, IAM roles, an EKS cluster, a one- or three-worker node group, an ECR repository, and a load-balanced web page — using Terraform on Spinifex." +seoTitle: "Terraform EKS Quickstart on Spinifex — Spinifex Docs" +description: "Stand up a minimal managed Kubernetes cluster with Terraform: a VPC, IAM roles, an EKS cluster, a worker node group, an ECR repository, and a demo web app." category: "Terraform Workbooks" tags: - terraform diff --git a/docs/terraform-workbooks/nginx-alb/README.md b/docs/terraform-workbooks/nginx-alb/README.md index dd15cc238..9217f8f05 100644 --- a/docs/terraform-workbooks/nginx-alb/README.md +++ b/docs/terraform-workbooks/nginx-alb/README.md @@ -1,6 +1,7 @@ --- title: "Nginx Web Server (Load Balanced)" -description: "Deploy a VPC with two private EC2 instances running Nginx, fronted by an internet-facing ALB using Terraform on Spinifex." +seoTitle: "Load-Balanced Nginx Behind an AWS ALB — Spinifex Docs" +description: "Deploy a VPC with two private EC2 instances running Nginx behind an internet-facing Application Load Balancer on Spinifex, using Terraform or OpenTofu." category: "Terraform Workbooks" tags: - terraform diff --git a/docs/terraform-workbooks/nginx-webserver/README.md b/docs/terraform-workbooks/nginx-webserver/README.md index 9e84a7360..d086b7c97 100644 --- a/docs/terraform-workbooks/nginx-webserver/README.md +++ b/docs/terraform-workbooks/nginx-webserver/README.md @@ -1,6 +1,7 @@ --- title: "Nginx Web Server" -description: "Deploy a VPC with a public subnet and an EC2 instance running Nginx using Terraform on Spinifex." +seoTitle: "Terraform Nginx Web Server on Spinifex — Spinifex Docs" +description: "Provision a VPC, public subnet, internet gateway, route table, security group, and an EC2 instance that installs and starts Nginx from cloud-init user-data." category: "Terraform Workbooks" tags: - terraform diff --git a/docs/terraform-workbooks/rds-quickstart/README.md b/docs/terraform-workbooks/rds-quickstart/README.md index c51a64821..eb5eedad7 100644 --- a/docs/terraform-workbooks/rds-quickstart/README.md +++ b/docs/terraform-workbooks/rds-quickstart/README.md @@ -1,6 +1,7 @@ --- title: "RDS Quickstart (PostgreSQL)" -description: "Stand up a managed PostgreSQL database on Spinifex with Terraform — a VPC, a DB subnet group, a parameter group, an aws_db_instance, and a client VM inside the VPC that connects to it with psql." +seoTitle: "Terraform RDS PostgreSQL Quickstart — Spinifex Docs" +description: "Stand up a managed PostgreSQL database on Spinifex with Terraform: a VPC, DB subnet group, parameter group, aws_db_instance, and a client VM that runs psql." category: "Terraform Workbooks" tags: - terraform @@ -53,6 +54,8 @@ The client subnet is public in that it has an internet-gateway route, which the The database security group admits `5432` from the client security group and nothing else, which compiles to an ACL on the endpoint ENI itself — the port is deny-by-default for everything else in the VPC. +That holds for the instance, not just for the endpoint ENI. The DB VM has two further NICs the platform uses to manage it, and no customer security group governs either; the engine binds the endpoint ENI's address alone, so it is not listening on them. PostgreSQL's client authentication rules are scoped to this VPC's own range as well, so the endpoint really is the whole of the reachable surface. + ## Prerequisites - **Spinifex running**, with the AWS CLI configured for the `spinifex` profile (see [Installing Spinifex](/docs/install)) and OpenTofu (or Terraform) installed. diff --git a/docs/terraform-workbooks/s3-webapp/README.md b/docs/terraform-workbooks/s3-webapp/README.md index 53c47a167..bcb5315a1 100644 --- a/docs/terraform-workbooks/s3-webapp/README.md +++ b/docs/terraform-workbooks/s3-webapp/README.md @@ -1,6 +1,7 @@ --- title: "S3-Backed Web App" -description: "Deploy a Flask file-sharing web application backed by S3 (Predastore) using Terraform on Spinifex." +seoTitle: "Terraform S3-Backed Flask Web App on EC2 — Spinifex Docs" +description: "Deploy a Flask file-sharing app on EC2 backed by S3 (Predastore) with Terraform, using an IAM instance profile and short-lived STS credentials from IMDS." category: "Terraform Workbooks" tags: - terraform diff --git a/internal/fipsboot/fipsboot.go b/internal/fipsboot/fipsboot.go index c3d711909..b659abf08 100644 --- a/internal/fipsboot/fipsboot.go +++ b/internal/fipsboot/fipsboot.go @@ -9,6 +9,7 @@ import ( func init() { if !fips140.Enabled() { + //nolint:forbidigo // Refuse to start when the required FIPS mode is disabled. panic("fipsboot: FIPS 140-3 mode is not enabled at runtime — refusing to start. " + "Build with GOFIPS140=v1.0.0 and do not set GODEBUG=fips140=off.") } diff --git a/scripts/images/rds-mariadb/rds-agent.initd b/scripts/images/rds-mariadb/rds-agent.initd index a01ff9d41..d3d4cee33 100644 --- a/scripts/images/rds-mariadb/rds-agent.initd +++ b/scripts/images/rds-mariadb/rds-agent.initd @@ -35,7 +35,7 @@ start_pre() { # shellcheck disable=SC1090 . "${AGENT_ENV}" export RDS_GATEWAY_URL RDS_GATEWAY_CA RDS_REGION RDS_DB_INSTANCE_IDENTIFIER \ - RDS_ENGINE RDS_ENGINE_VERSION RDS_HANDOFF_DIR RDS_ENGINE_HOST RDS_ENGINE_PORT \ + RDS_ENGINE RDS_ENGINE_VERSION RDS_HANDOFF_DIR RDS_ENGINE_PORT \ RDS_POLL_WAIT RDS_DATA_MOUNT fi } diff --git a/scripts/images/rds-mariadb/rds-init b/scripts/images/rds-mariadb/rds-init index b5af000cc..35695b1c1 100644 --- a/scripts/images/rds-mariadb/rds-init +++ b/scripts/images/rds-mariadb/rds-init @@ -14,6 +14,7 @@ # RDS_MASTER_PASSWORD= # RDS_DB_NAME= # RDS_PORT= +# RDS_LISTEN_ADDRESS= # RDS_DATA_VOLUME_ID / RDS_DATA_VOLUME_SERIAL= # RDS_VM_GENERATION / RDS_FORMAT_AUTHORIZED= # RDS_BOOTSTRAP_PENDING / RDS_PAYLOAD_ID=&2; exit 1; } +# A dotted-quad IPv4 literal. bind_address would also take a hostname, but the +# control plane sends an address, and accepting a name would reintroduce the +# resolution step the bind exists to remove. +is_ipv4() { + case "$1" in + '' | *[!0-9.]*) return 1 ;; + esac + _rest="$1" + _octets=0 + while [ -n "${_rest}" ]; do + _octet="${_rest%%.*}" + case "${_rest}" in + *.*) _rest="${_rest#*.}" ;; + *) _rest="" ;; + esac + # A leading zero reads as octal to some parsers and decimal to others, + # so the two would disagree about which address was bound. + case "${_octet}" in + 0 | [1-9] | [1-9][0-9] | [1-9][0-9][0-9]) ;; + *) return 1 ;; + esac + [ "${_octet}" -le 255 ] || return 1 + _octets=$((_octets + 1)) + done + [ "${_octets}" = 4 ] +} + +# The engine binds one address and refuses to start if it is not there yet, and +# an engine that will not start for an unexplained reason is the worst way to +# learn that DHCP lost its race. Bounded, then fatal with the address named. +wait_for_listen_address() { + _deadline=$(($(date +%s) + LISTEN_ADDRESS_TIMEOUT)) + while :; do + if ip -4 -o addr show 2>/dev/null | + awk -v want="${RDS_LISTEN_ADDRESS}" \ + '{ split($4, addr, "/"); if (addr[1] == want) found = 1 } END { exit !found }'; then + return 0 + fi + [ "$(date +%s)" -lt "${_deadline}" ] || break + sleep 1 + done + fail "the endpoint address ${RDS_LISTEN_ADDRESS} did not appear on any interface within ${LISTEN_ADDRESS_TIMEOUT}s; the engine binds it and nothing else, so refusing to start" +} + # Between these two, the datadir carries a mysql system database but no usable # master user. _server_pid is the private bootstrap server, if one is running. _created_datadir=0 @@ -199,6 +249,7 @@ load_handoff() { RDS_MASTER_PASSWORD="${RDS_MASTER_PASSWORD:-}" RDS_DB_NAME="${RDS_DB_NAME:-}" RDS_PORT="${RDS_PORT:-3306}" + RDS_LISTEN_ADDRESS="${RDS_LISTEN_ADDRESS:-}" RDS_VM_GENERATION="${RDS_VM_GENERATION:-}" # Absent from an agent that predates the receipt protocol, which reads as # "nothing is pending" and leaves every path below unchanged. @@ -252,6 +303,14 @@ load_handoff() { '' | *[!0-9]*) fail "bootstrap config has a non-numeric RDS_PORT: ${RDS_PORT}" ;; esac + # No fallback to a wildcard. The engine is reachable over the customer ENI + # and nowhere else, and a handoff that cannot say which address that is has + # no safe reading — the DB VM's other two NICs carry no security group the + # customer can see, let alone set. + [ -n "${RDS_LISTEN_ADDRESS}" ] || fail "bootstrap config has no RDS_LISTEN_ADDRESS; refusing to start an engine that would answer on every interface" + is_ipv4 "${RDS_LISTEN_ADDRESS}" || + fail "bootstrap config has a non-IPv4 RDS_LISTEN_ADDRESS: ${RDS_LISTEN_ADDRESS}" + # redact_stream is line-oriented, so a password spanning a line boundary # would match neither half and reach /dev/console in the clear. The control # plane rejects one; refusing here too keeps that an asserted property of @@ -537,7 +596,8 @@ EOF # The 90- prefix sorts last within the include directory and MariaDB takes the # last occurrence of a setting, so a resolved parameter can never move the -# endpoint's port, redirect the datadir, turn off TLS or lower the protocol floor. +# endpoint's port, widen the bind, redirect the datadir, turn off TLS or lower +# the protocol floor. # # innodb_buffer_pool_chunk_size and innodb_buffer_pool_instances are deliberately # absent although they are platform-owned: 11.8 removed the second outright, so @@ -554,7 +614,14 @@ write_platform_config() { port = ${RDS_PORT} datadir = ${DATADIR} socket = ${SOCKET} -bind_address = 0.0.0.0 + +# The customer ENI and nothing else. The DB VM's other two NICs sit on the +# shared RDS system VPC and the host's management bridge, neither of which any +# customer security group governs, so a socket bound there is reachable across +# accounts. MariaDB's analogue of a scoped pg_hba is the host pattern on a +# grant, which lives in mysql.user on the data volume and would survive a +# restore into another VPC — so for this engine the bind is the boundary. +bind_address = ${RDS_LISTEN_ADDRESS} # InnoDB is the only storage engine the snapshot guarantee holds for: it keeps a # redo log and recovers from a crash-consistent snapshot, and Aria and MyISAM do @@ -963,6 +1030,7 @@ consume_secrets() { } load_handoff +wait_for_listen_address require_data_volume require_engine_stamp diff --git a/scripts/images/rds-mariadb/rds-init_test.sh b/scripts/images/rds-mariadb/rds-init_test.sh index e9c3cd047..88725bfd2 100755 --- a/scripts/images/rds-mariadb/rds-init_test.sh +++ b/scripts/images/rds-mariadb/rds-init_test.sh @@ -201,6 +201,18 @@ EOF # an installed receipt and an armed sweep coexist. Matched on the receipt # directory by name: the engine stamp syncs its directory too, long before the # traps are armed, and killing there would leave the window untested. +# ip stub: stands in for `ip -4 -o addr show`, printing one line per address in +# IP_ADDR_LIST in the format the bind-address wait parses. +cat > "${STUBBIN}/ip" <<'EOF' +#!/bin/sh +n=1 +for cidr in ${IP_ADDR_LIST:-}; do + echo "${n}: eth$((n - 1)) inet ${cidr} scope global eth$((n - 1))" + n=$((n + 1)) +done +exit 0 +EOF + cat > "${STUBBIN}/sync" <<'EOF' #!/bin/sh if [ "${SYNC_KILL_PARENT:-0}" = "1" ]; then @@ -236,6 +248,9 @@ SECURE_FILE_DIR="${WORK}/mysql-files" LOG_DIR="${WORK}/log" ENGINE_LOG="${LOG_DIR}/error.log" MOUNTS="${WORK}/mounts" + +# The customer ENI address the engine binds, as the control plane delivers it. +LISTEN_ADDRESS_DEFAULT="10.30.1.15" KILL_PID_FILE="${WORK}/rds-init.pid" MARIADBD_TEST_PID_FILE="${WORK}/mariadbd.pid" WORKDIR="${WORK}" @@ -266,6 +281,13 @@ write_handoff() { [ -n "$3" ] && echo "RDS_DB_NAME='$3'" [ -n "${PENDING:-}" ] && echo "RDS_BOOTSTRAP_PENDING=${PENDING}" [ -n "${PAYLOAD_ID:-}" ] && echo "RDS_PAYLOAD_ID=${PAYLOAD_ID}" + # Written unset rather than defaulted when the case is about a handoff + # that omits it, which rds-init has to refuse rather than fill in. + [ "${OMIT_LISTEN_ADDRESS:-0}" = "1" ] || + echo "RDS_LISTEN_ADDRESS=${LISTEN_ADDRESS:-${LISTEN_ADDRESS_DEFAULT}}" + # Delivered but unread here: MariaDB's analogue of a scoped pg_hba is a + # grant host pattern on the data volume, which a restore would carry. + echo "RDS_CLIENT_CIDR=10.30.0.0/16" # Last, and unconditional: an optional line failing its test would make # the whole group's status non-zero and `set -e` would end the run here. echo "RDS_PORT=6543" @@ -327,6 +349,10 @@ reset_state() { unset PENDING PAYLOAD_ID DB_ID RECEIPT_DIR_OVERRIDE CLIENT_STATUS_CORRUPT STAMP_OVERRIDE || true unset MARIADBD_START_FAIL MARIADBD_KILL_PARENT MARIADBD_IGNORE_TERM CLIENT_KILL_PARENT || true unset ADMIN_SHUTDOWN_FAIL SYNC_KILL_PARENT || true + unset LISTEN_ADDRESS OMIT_LISTEN_ADDRESS || true + # The customer ENI carries its address by the time rds-init runs, which is + # the ordinary case; the cases about a late lease drop it themselves. + IP_ADDR_LIST="${LISTEN_ADDRESS_DEFAULT}/24 10.99.0.7/24" rm -f "${MARIADBD_TEST_PID_FILE}" write_tls } @@ -346,6 +372,8 @@ run_env() { RDS_RECEIPT_DIR="${RECEIPT_DIR_OVERRIDE:-${RECEIPT_DIR}}" \ RDS_ENGINE_STAMP="${STAMP_OVERRIDE:-${STAMP}}" \ RDS_ALLOW_LOCAL_DATADIR="${RDS_ALLOW_LOCAL_DATADIR:-0}" \ + RDS_LISTEN_ADDRESS_TIMEOUT="${LISTEN_TIMEOUT:-0}" \ + IP_ADDR_LIST="${IP_ADDR_LIST:-}" \ RDS_BOOTSTRAP_POLL=0.05 \ RDS_BOOTSTRAP_PROBES=200 \ RDS_BOOTSTRAP_STOP_GRACE=0.1 \ @@ -493,9 +521,12 @@ if run_ok "initialize"; then && pass "initialize: delivered port applied" || fail "initialize: port not applied" grep -q "^datadir = ${DATADIR}" "${PLATFORM_FILE}" \ && pass "initialize: datadir pinned by the platform" || fail "initialize: no datadir" - grep -q "^bind_address = 0.0.0.0" "${PLATFORM_FILE}" \ - && pass "initialize: the engine listens for the customer ENI" \ - || fail "initialize: no bind_address" + grep -q "^bind_address = ${LISTEN_ADDRESS_DEFAULT}$" "${PLATFORM_FILE}" \ + && pass "initialize: the engine binds the customer ENI" \ + || fail "initialize: the engine does not bind the delivered address" + grep -qE '^bind_address = (0\.0\.0\.0|\*|::)$' "${PLATFORM_FILE}" \ + && fail "initialize: the engine still answers on every interface" \ + || pass "initialize: no wildcard bind" grep -q "^default_storage_engine = InnoDB" "${PLATFORM_FILE}" \ && pass "initialize: tables land in the engine snapshots recover" \ || fail "initialize: default_storage_engine not pinned" @@ -1233,6 +1264,38 @@ grep -q 'engine stamp' "${WORK}/out" \ && pass "stamp-unwritable: refusal names the stamp" || fail "stamp-unwritable: no refusal message" unset STAMP_OVERRIDE +# --- Case 13: a handoff that cannot say what to bind is refused --- +# The refusal is before the one-shot password is spent, so the create is still +# retryable. The only reading rds-init could reach on its own is the wildcard +# the bind exists to remove. +for _case in omit-listen bad-listen leading-zero short-quad; do + reset_state + case "${_case}" in + omit-listen) OMIT_LISTEN_ADDRESS=1 ;; + bad-listen) LISTEN_ADDRESS="db.internal" ;; + leading-zero) LISTEN_ADDRESS="010.30.1.15" ;; + short-quad) LISTEN_ADDRESS="10.30.1" ;; + esac + write_handoff initialize 's3cr3t' appdb + run_fails "${_case}" + [ -s "${INSTALLDB_CALLS}" ] \ + && fail "${_case}: initialised on a handoff that could not be read" \ + || pass "${_case}: refused before the datadir was created" +done + +# --- Case 13a: the bind address must be on an interface before the engine starts --- +# An engine asked to bind an address that is not there refuses to start, which is +# the worst way to learn that DHCP lost its race. +reset_state +IP_ADDR_LIST="10.99.0.7/24" +write_handoff initialize 's3cr3t' appdb +run_fails "bind-wait" +grep -q "${LISTEN_ADDRESS_DEFAULT}" "${WORK}/out" \ + && pass "bind-wait: the refusal names the address" || fail "bind-wait: the refusal does not name the address" +[ -s "${INSTALLDB_CALLS}" ] \ + && fail "bind-wait: initialised before the address existed" \ + || pass "bind-wait: refused before the datadir was created" + if [ "${FAILS}" -eq 0 ]; then echo "PASS: all rds-init cases" exit 0 diff --git a/scripts/images/rds-postgres/rds-agent.initd b/scripts/images/rds-postgres/rds-agent.initd index 406d1549e..34011febd 100644 --- a/scripts/images/rds-postgres/rds-agent.initd +++ b/scripts/images/rds-postgres/rds-agent.initd @@ -35,7 +35,7 @@ start_pre() { # shellcheck disable=SC1090 . "${AGENT_ENV}" export RDS_GATEWAY_URL RDS_GATEWAY_CA RDS_REGION RDS_DB_INSTANCE_IDENTIFIER \ - RDS_ENGINE RDS_ENGINE_VERSION RDS_HANDOFF_DIR RDS_ENGINE_HOST RDS_ENGINE_PORT \ + RDS_ENGINE RDS_ENGINE_VERSION RDS_HANDOFF_DIR RDS_ENGINE_PORT \ RDS_POLL_WAIT RDS_DATA_MOUNT fi } diff --git a/scripts/images/rds-postgres/rds-init b/scripts/images/rds-postgres/rds-init index dec7dc237..3c9711774 100644 --- a/scripts/images/rds-postgres/rds-init +++ b/scripts/images/rds-postgres/rds-init @@ -14,6 +14,8 @@ # RDS_MASTER_PASSWORD= # RDS_DB_NAME= # RDS_PORT= +# RDS_LISTEN_ADDRESS= +# RDS_CLIENT_CIDR= # RDS_DATA_VOLUME_ID / RDS_DATA_VOLUME_SERIAL= # RDS_VM_GENERATION / RDS_FORMAT_AUTHORIZED= # RDS_BOOTSTRAP_PENDING / RDS_PAYLOAD_ID=/dev/null | + awk -v want="${RDS_LISTEN_ADDRESS}" \ + '{ split($4, addr, "/"); if (addr[1] == want) found = 1 } END { exit !found }'; then + return 0 + fi + [ "$(date +%s)" -lt "${_deadline}" ] || break + sleep 1 + done + fail "the endpoint address ${RDS_LISTEN_ADDRESS} did not appear on any interface within ${LISTEN_ADDRESS_TIMEOUT}s; the engine binds it and nothing else, so refusing to start" +} + load_handoff() { [ -f "${ENV_FILE}" ] || fail "no bootstrap handoff at ${ENV_FILE} — rds-agent has not delivered the bootstrap config" @@ -146,6 +210,8 @@ load_handoff() { RDS_MASTER_PASSWORD="${RDS_MASTER_PASSWORD:-}" RDS_DB_NAME="${RDS_DB_NAME:-}" RDS_PORT="${RDS_PORT:-5432}" + RDS_LISTEN_ADDRESS="${RDS_LISTEN_ADDRESS:-}" + RDS_CLIENT_CIDR="${RDS_CLIENT_CIDR:-}" RDS_VM_GENERATION="${RDS_VM_GENERATION:-}" # Absent from an agent that predates the receipt protocol, which reads as # "nothing is pending" and leaves every path below unchanged. @@ -192,6 +258,17 @@ load_handoff() { '' | *[!0-9]*) fail "bootstrap config has a non-numeric RDS_PORT: ${RDS_PORT}" ;; esac + # Neither falls back to a wildcard. The engine is reachable over the customer + # ENI and nowhere else, and a handoff that cannot say which address that is + # has no safe reading — the DB VM's other two NICs carry no security group + # the customer can see, let alone set. + [ -n "${RDS_LISTEN_ADDRESS}" ] || fail "bootstrap config has no RDS_LISTEN_ADDRESS; refusing to start an engine that would answer on every interface" + is_ipv4 "${RDS_LISTEN_ADDRESS}" || + fail "bootstrap config has a non-IPv4 RDS_LISTEN_ADDRESS: ${RDS_LISTEN_ADDRESS}" + [ -n "${RDS_CLIENT_CIDR}" ] || fail "bootstrap config has no RDS_CLIENT_CIDR; refusing to start an engine that would accept clients from anywhere" + is_ipv4_cidr "${RDS_CLIENT_CIDR}" || + fail "bootstrap config has a non-IPv4 RDS_CLIENT_CIDR: ${RDS_CLIENT_CIDR}" + # redact_stream is line-oriented, so a password spanning a line boundary # would match neither half and reach /dev/console in the clear. The control # plane rejects one; refusing here too keeps that an asserted property of @@ -443,13 +520,15 @@ write_pg_hba() { # Managed by rds-init — regenerated on every boot. Edits here are lost. # # Clients reach the instance over its customer ENI, gated by the DB security -# group as on AWS RDS, and authenticate with scram-sha-256. The catch-alls cover -# loopback too, so initdb's own 127.0.0.1 and ::1 lines are not carried over. -# The local socket is how rds-init and rds-agent run privileged SQL. +# group as on AWS RDS, and authenticate with scram-sha-256. The host rule is +# scoped to the customer VPC rather than the DB subnet: an app subnet reaching a +# DB subnet arrives carrying its own address, with no NAT in between. The engine +# does not bind loopback, so initdb's own 127.0.0.1 and ::1 lines govern nothing +# and are not carried over. The local socket is how rds-init and rds-agent run +# privileged SQL. local all all peer include_if_exists "${HBA_FORCE_SSL_INCLUDE}" -host all all 0.0.0.0/0 scram-sha-256 -host all all ::/0 scram-sha-256 +host all all ${RDS_CLIENT_CIDR} scram-sha-256 EOF chown "${PG_USER}:${PG_USER}" "${_tmp}" chmod 0600 "${_tmp}" @@ -466,8 +545,13 @@ EOF } # The 90- prefix sorts last within include_dir and the last setting wins, so a -# resolved parameter can never move the endpoint's port, turn off TLS or lower -# the protocol floor. +# resolved parameter can never move the endpoint's port, widen the bind, turn off +# TLS or lower the protocol floor. +# +# listen_addresses is the customer ENI and nothing else — not the wildcard, and +# not loopback either. The DB VM's other two NICs sit on the shared RDS system +# VPC and the host's management bridge, neither of which any customer security +# group governs, so a socket bound there is reachable across accounts. # # The floor is written whether or not a cert was delivered: it costs nothing with # ssl off, and making it conditional would mean a boot without a cert leaves the @@ -478,7 +562,7 @@ write_rds_conf() { cat > "${_tmp}" < "${STUBBIN}/ls" <<'EOF' exec "${REAL_LS}" "$@" EOF +# ip stub: stands in for `ip -4 -o addr show`, printing one line per address in +# IP_ADDR_LIST in the format the bind-address wait parses. +cat > "${STUBBIN}/ip" <<'EOF' +#!/bin/sh +n=1 +for cidr in ${IP_ADDR_LIST:-}; do + echo "${n}: eth$((n - 1)) inet ${cidr} scope global eth$((n - 1))" + n=$((n + 1)) +done +exit 0 +EOF + # su stub: `su postgres -c "cmd"` runs cmd in the harness user's shell. cat > "${STUBBIN}/su" <<'EOF' #!/bin/sh @@ -205,6 +217,11 @@ RECEIPT="${RECEIPT_DIR}/receipt.env" STAMP="${DATA_MOUNT}/.spinifex-rds/engine" HANDOFF="${WORK}/run/spinifex-rds" MOUNTS="${WORK}/mounts" + +# The customer ENI address the engine binds and the VPC range its client +# authentication admits, as the control plane delivers them. +LISTEN_ADDRESS_DEFAULT="10.30.1.15" +CLIENT_CIDR_DEFAULT="10.30.0.0/16" KILL_PID_FILE="${WORK}/rds-init.pid" export KILL_PID_FILE @@ -234,6 +251,12 @@ write_handoff() { [ -n "$3" ] && echo "RDS_DB_NAME='$3'" [ -n "${PENDING:-}" ] && echo "RDS_BOOTSTRAP_PENDING=${PENDING}" [ -n "${PAYLOAD_ID:-}" ] && echo "RDS_PAYLOAD_ID=${PAYLOAD_ID}" + # Written unset rather than defaulted when the case is about a handoff + # that omits one, which rds-init has to refuse rather than fill in. + [ "${OMIT_LISTEN_ADDRESS:-0}" = "1" ] || + echo "RDS_LISTEN_ADDRESS=${LISTEN_ADDRESS:-${LISTEN_ADDRESS_DEFAULT}}" + [ "${OMIT_CLIENT_CIDR:-0}" = "1" ] || + echo "RDS_CLIENT_CIDR=${CLIENT_CIDR:-${CLIENT_CIDR_DEFAULT}}" # Last, and unconditional: an optional line failing its test would make # the whole group's status non-zero and `set -e` would end the run here. echo "RDS_PORT=6543" @@ -270,6 +293,10 @@ reset_state() { : > "${PSQL_CALLS}" unset INITDB_FAIL PG_HBA_AS_DIR PSQL_FAIL LS_FAIL RDS_ALLOW_LOCAL_DATADIR MASTER_USER || true unset PENDING PAYLOAD_ID DB_ID RECEIPT_DIR_OVERRIDE PSQL_STATUS_CORRUPT STAMP_OVERRIDE || true + unset LISTEN_ADDRESS CLIENT_CIDR OMIT_LISTEN_ADDRESS OMIT_CLIENT_CIDR || true + # The customer ENI carries its address by the time rds-init runs, which is + # the ordinary case; the cases about a late lease drop it themselves. + IP_ADDR_LIST="${LISTEN_ADDRESS_DEFAULT}/24 10.99.0.7/24" write_tls } @@ -293,6 +320,8 @@ run_env() { RDS_RECEIPT_DIR="${RECEIPT_DIR_OVERRIDE:-${RECEIPT_DIR}}" \ RDS_ENGINE_STAMP="${STAMP_OVERRIDE:-${STAMP}}" \ RDS_ALLOW_LOCAL_DATADIR="${RDS_ALLOW_LOCAL_DATADIR:-0}" \ + RDS_LISTEN_ADDRESS_TIMEOUT="${LISTEN_TIMEOUT:-0}" \ + IP_ADDR_LIST="${IP_ADDR_LIST:-}" \ INITDB_FAIL="${INITDB_FAIL:-0}" \ PG_HBA_AS_DIR="${PG_HBA_AS_DIR:-0}" \ PSQL_FAIL="${PSQL_FAIL:-0}" \ @@ -328,14 +357,24 @@ if run_ok "initialize"; then grep -q 'initdb' "${INITDB_CALLS}" && pass "initialize: initdb ran" || fail "initialize: no initdb" grep -q -- '--data-checksums' "${INITDB_CALLS}" \ && pass "initialize: data checksums on" || fail "initialize: no --data-checksums" - grep -q 'host all all 0.0.0.0/0 scram-sha-256' "${PGDATA}/pg_hba.conf" \ - && pass "initialize: remote scram hba rule written" || fail "initialize: no hba rule" + grep -q "host all all ${CLIENT_CIDR_DEFAULT} scram-sha-256" "${PGDATA}/pg_hba.conf" \ + && pass "initialize: the scram hba rule is scoped to the customer VPC" || fail "initialize: no hba rule" + + # The scope is the whole boundary this file carries: a catch-all here admits + # the system VPC and the management bridge, which no customer security group + # governs. IPv6 never appears — the platform's VPCs are IPv4-only. + grep -qE 'host .*(0\.0\.0\.0/0|::/0)' "${PGDATA}/pg_hba.conf" \ + && fail "initialize: a catch-all host rule survived the VPC scoping" \ + || pass "initialize: no catch-all host rule" + [ "$(grep -c '^host ' "${PGDATA}/pg_hba.conf")" = 1 ] \ + && pass "initialize: exactly one host rule" \ + || fail "initialize: more than one host rule, so the scope is not the boundary" # pg_hba is first-match-wins, so initdb's loopback rules sorting above the - # catch-all would shadow anything the platform appends below them. rds-init - # owns the whole file; the catch-alls cover loopback themselves. + # platform's would shadow it. rds-init owns the whole file, and the engine + # does not bind loopback at all. grep -qE '127\.0\.0\.1/32|::1/128' "${PGDATA}/pg_hba.conf" \ - && fail "initialize: initdb's loopback TCP rules survived above the catch-all" \ + && fail "initialize: initdb's loopback TCP rules survived" \ || pass "initialize: initdb's loopback TCP rules are gone" grep -q 'replication' "${PGDATA}/pg_hba.conf" \ && fail "initialize: initdb's replication rules survived" \ @@ -358,6 +397,12 @@ if run_ok "initialize"; then && pass "initialize: include_dir hooked" || fail "initialize: no include_dir" grep -q '^port = 6543' "${PGDATA}/conf.d/90-rds-init.conf" \ && pass "initialize: delivered port applied" || fail "initialize: port not applied" + grep -q "^listen_addresses = '${LISTEN_ADDRESS_DEFAULT}'$" "${PGDATA}/conf.d/90-rds-init.conf" \ + && pass "initialize: the engine binds the customer ENI" \ + || fail "initialize: the engine does not bind the delivered address" + grep -q "^listen_addresses = '\*'" "${PGDATA}/conf.d/90-rds-init.conf" \ + && fail "initialize: the engine still answers on every interface" \ + || pass "initialize: no wildcard bind" grep -q '^ssl = on' "${PGDATA}/conf.d/90-rds-init.conf" \ && pass "initialize: ssl enabled" || fail "initialize: ssl not enabled" grep -q "^ssl_min_protocol_version = 'TLSv1.3'$" "${PGDATA}/conf.d/90-rds-init.conf" \ @@ -1159,6 +1204,69 @@ grep -q 'engine stamp' "${WORK}/out" \ && pass "stamp-unwritable: refusal names the stamp" || fail "stamp-unwritable: no refusal message" unset STAMP_OVERRIDE +# --- Case 12: a handoff that cannot say what to bind is refused before initdb --- +# The refusal is before the one-shot password is spent, so the create is still +# retryable. The only readings rds-init could reach on its own are the wildcards +# the bind exists to remove. +for _case in omit-listen bad-listen omit-cidr bad-cidr; do + reset_state + case "${_case}" in + omit-listen) OMIT_LISTEN_ADDRESS=1 ;; + bad-listen) LISTEN_ADDRESS="db.internal" ;; + omit-cidr) OMIT_CLIENT_CIDR=1 ;; + bad-cidr) CLIENT_CIDR="10.30.0.0/33" ;; + esac + write_handoff initialize 's3cr3t' appdb + run_fails "${_case}" + [ -s "${INITDB_CALLS}" ] \ + && fail "${_case}: initdb ran on a handoff that could not be read" \ + || pass "${_case}: refused before initdb" +done + +# --- Case 12a: an octet-shaped address that is not one is refused --- +# 10.30.1.256 and 010.30.1.15 both parse as addresses to a permissive check, and +# the second binds a different host to anything reading the leading zero as octal. +for _addr in 10.30.1.256 010.30.1.15 10.30.1 10.30.1.15.1; do + reset_state + LISTEN_ADDRESS="${_addr}" + write_handoff initialize 's3cr3t' appdb + run_fails "listen-${_addr}" +done + +# --- Case 13: the bind address must be on an interface before the engine starts --- +# An engine asked to bind an address that is not there exits with `could not +# bind`, which is the worst way to learn that DHCP lost its race. +reset_state +IP_ADDR_LIST="10.99.0.7/24" +write_handoff initialize 's3cr3t' appdb +run_fails "bind-wait" +grep -q "${LISTEN_ADDRESS_DEFAULT}" "${WORK}/out" \ + && pass "bind-wait: the refusal names the address" || fail "bind-wait: the refusal does not name the address" +[ -s "${INITDB_CALLS}" ] \ + && fail "bind-wait: initdb ran before the address existed" \ + || pass "bind-wait: refused before initdb" + +# --- Case 13a: a restored volume takes the destination VPC's scope --- +# A restore attaches a data volume carrying the source instance's pg_hba into a +# possibly different VPC. Regenerating the whole file every boot is what stops +# the restored instance locking out its own VPC's clients. +reset_state +write_handoff initialize 's3cr3t' appdb +write_parameters +if run_ok "restore-scope-setup"; then + CLIENT_CIDR="172.31.0.0/16" + write_handoff attach '' appdb + write_parameters + if run_ok "restore-scope"; then + grep -q 'host all all 172.31.0.0/16 scram-sha-256' "${PGDATA}/pg_hba.conf" \ + && pass "restore-scope: the destination VPC's range is admitted" \ + || fail "restore-scope: the restored instance did not take its own VPC's scope" + grep -q "${CLIENT_CIDR_DEFAULT}" "${PGDATA}/pg_hba.conf" \ + && fail "restore-scope: the source VPC's range survived the restore" \ + || pass "restore-scope: the source VPC's range is gone" + fi +fi + if [ "${FAILS}" -eq 0 ]; then echo "PASS: all rds-init cases" exit 0 diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 27666dce9..afaac8a22 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -1702,8 +1702,20 @@ func (d *Daemon) startCluster() error { slog.Info("EIP service disabled — no external IPAM; serving empty/unsupported responses") } - d.instanceService.SetTerminationDeps(d.volumeService, d.vpcService, d.externalIPAM, d.tagsService) - d.instanceService.SetRunInstancesDeps(d.imageService, d.keyService, &daemonENICreator{d: d}, d.externalIPAM) + // A nil *ExternalIPAM stored in an interface is not a nil interface, so hand + // the concrete value over only once init succeeded — otherwise the service's + // own nil checks pass and the allocate path derefs a nil receiver. + var ( + ipAllocator handlers_ec2_instance.PublicIPAllocator + ipReleaser handlers_ec2_instance.PublicIPReleaser + ) + if d.externalIPAM != nil { + ipAllocator = d.externalIPAM + ipReleaser = d.externalIPAM + } + + d.instanceService.SetTerminationDeps(d.volumeService, d.vpcService, ipReleaser, d.tagsService) + d.instanceService.SetRunInstancesDeps(d.imageService, d.keyService, &daemonENICreator{d: d}, ipAllocator) if d.gpuManager != nil { d.instanceService.SetGPUClaimer(&daemonGPUClaimer{d: d}) @@ -1806,6 +1818,9 @@ func (d *Daemon) startCluster() error { // whichever node the queue group picks, so a node without it would make the // first boot of a DB instance fail intermittently rather than not at all. d.rdsService, err = initServiceWithRetry("RDS service", func() (*handlers_rds.Service, error) { + if registryErr := handlers_rds.ValidateEngineRegistry(); registryErr != nil { + return nil, registryErr + } deps, depsErr := d.buildRDSDeps() if depsErr != nil { return nil, depsErr diff --git a/spinifex/daemon/daemon_system_instance.go b/spinifex/daemon/daemon_system_instance.go index 25d523689..0b86df369 100644 --- a/spinifex/daemon/daemon_system_instance.go +++ b/spinifex/daemon/daemon_system_instance.go @@ -52,6 +52,25 @@ func (d *Daemon) recordENIInstanceOwner(eniAccountID, eniID, instanceOwnerID str } } +// attachExtraENI attaches one pre-created extra ENI and, when the caller asked +// for an explicit DeleteOnTermination, persists it onto the record. An ENI that +// has to outlive its VM is only safe once that flag is stored, so a failed stamp +// fails the attach rather than leaving a disposable NIC behind. +func (d *Daemon) attachExtraENI(eniAccountID string, extra sysinstance.ExtraENIInput, instanceID string, deviceIndex int64) error { + if _, err := d.vpcService.AttachENI(eniAccountID, extra.ENIID, instanceID, deviceIndex); err != nil { + return err + } + if extra.DeleteOnTermination == nil { + return nil + } + if err := d.vpcService.UpdateENI(eniAccountID, extra.ENIID, func(r *handlers_ec2_vpc.ENIRecord) { + r.DeleteOnTermination = extra.DeleteOnTermination + }); err != nil { + return fmt.Errorf("set DeleteOnTermination on ENI %s: %w", extra.ENIID, err) + } + return nil +} + // LaunchSystemInstance creates and starts a system-managed VM (ELBv2 LB). // The VM is owned by the system account (GlobalAccountID), is not visible to // customer DescribeInstances calls, and always boots via direct kernel boot @@ -160,7 +179,7 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput) // customer-VPC ENI) lives in its own account; the ENI record is // account-keyed, so attach under extra.AccountID when set. extraAccount := resolveENIAccount(extra.AccountID, eniAccountID) - if _, attachErr := d.vpcService.AttachENI(extraAccount, extra.ENIID, instance.ID, int64(idx+1)); attachErr != nil { + if attachErr := d.attachExtraENI(extraAccount, extra, instance.ID, int64(idx+1)); attachErr != nil { slog.Error("LaunchSystemInstance: failed to attach extra ENI", "eniId", extra.ENIID, "instanceId", instance.ID, "err", attachErr) d.cleanupFailedSystemInstance(instance, instanceType) return nil, fmt.Errorf("attach extra ENI %s: %w", extra.ENIID, attachErr) @@ -488,7 +507,7 @@ func (d *Daemon) launchAMISystemInstance(input *sysinstance.SystemInstanceInput) for idx, extra := range input.ExtraENIs { if d.vpcService != nil { extraAccount := resolveENIAccount(extra.AccountID, input.AccountID) - if _, attachErr := d.vpcService.AttachENI(extraAccount, extra.ENIID, inst.ID, int64(idx+1)); attachErr != nil { + if attachErr := d.attachExtraENI(extraAccount, extra, inst.ID, int64(idx+1)); attachErr != nil { slog.Error("launchAMISystemInstance: failed to attach extra ENI", "eniId", extra.ENIID, "instanceId", inst.ID, "err", attachErr) d.vmMgr.MarkFailed(context.Background(), inst, "extra_eni_attach_failed") return nil, fmt.Errorf("attach extra ENI %s: %w", extra.ENIID, attachErr) diff --git a/spinifex/daemon/daemon_system_instance_test.go b/spinifex/daemon/daemon_system_instance_test.go index bc9ee8fcd..f65b54a0c 100644 --- a/spinifex/daemon/daemon_system_instance_test.go +++ b/spinifex/daemon/daemon_system_instance_test.go @@ -12,10 +12,12 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip" handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/spinifex/handlers/elbv2" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/handlers/sysinstance" "github.com/mulgadc/spinifex/spinifex/network/external" "github.com/mulgadc/spinifex/spinifex/tags" "github.com/mulgadc/spinifex/spinifex/testutil" @@ -877,3 +879,50 @@ func TestReleaseSystemInstanceEIP_ReleasesEipServiceAllocation(t *testing.T) { assert.Empty(t, inst.PublicIPAllocID, "instance alloc ID must be cleared so externalIPAM teardown does not double-release") assert.Empty(t, inst.PublicIPAssocID) } + +// --- attachExtraENI: DeleteOnTermination --- + +// TestAttachExtraENI_DeleteOnTerminationFalseSurvivesTerminate covers the RDS +// replace: the customer/endpoint ENI is attached as an extra NIC and has to +// outlive the VM, because a replacement launch resolves the endpoint by that +// ENI ID. Without the explicit flag the terminate sweep deletes it and the +// replacement fails with InvalidNetworkInterfaceID.NotFound. +func TestAttachExtraENI_DeleteOnTerminationFalseSurvivesTerminate(t *testing.T) { + f := newENIHotPlugFixture(t) + f.vmInst.AccountID = testAccountID + + require.NoError(t, f.daemon.attachExtraENI(testAccountID, sysinstance.ExtraENIInput{ + ENIID: f.eniID, + DeleteOnTermination: aws.Bool(false), + }, f.vmInst.ID, 1)) + + rec, err := f.daemon.vpcService.GetENIRecord(testAccountID, f.eniID) + require.NoError(t, err) + require.NotNil(t, rec.DeleteOnTermination, "the attach must stamp the flag, not leave the attach-time default") + assert.False(t, *rec.DeleteOnTermination) + + require.NoError(t, newInstanceCleanerAdapter(f.daemon).DetachAndDeleteENI(f.vmInst)) + + rec, err = f.daemon.vpcService.GetENIRecord(testAccountID, f.eniID) + require.NoError(t, err, "the endpoint ENI must survive the terminate half of a replace") + assert.Equal(t, "available", rec.Status, "it must be detached, so the replacement VM can re-attach it") + assert.Empty(t, rec.InstanceId) +} + +// TestAttachExtraENI_NilDeleteOnTerminationStaysDisposable holds the ELBv2 +// multi-subnet ALB and EKS cross-account NLB extras at their current behaviour: +// they carry no flag and are deleted with the VM. +func TestAttachExtraENI_NilDeleteOnTerminationStaysDisposable(t *testing.T) { + f := newENIHotPlugFixture(t) + f.vmInst.AccountID = testAccountID + + require.NoError(t, f.daemon.attachExtraENI(testAccountID, sysinstance.ExtraENIInput{ + ENIID: f.eniID, + }, f.vmInst.ID, 1)) + + require.NoError(t, newInstanceCleanerAdapter(f.daemon).DetachAndDeleteENI(f.vmInst)) + + _, err := f.daemon.vpcService.GetENIRecord(testAccountID, f.eniID) + require.Error(t, err, "an extra ENI with no explicit flag stays disposable") + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidNetworkInterfaceIDNotFound)) +} diff --git a/spinifex/gateway/auth_test.go b/spinifex/gateway/auth_test.go index c1dd2a089..2ef7514fa 100644 --- a/spinifex/gateway/auth_test.go +++ b/spinifex/gateway/auth_test.go @@ -1971,7 +1971,7 @@ func setupPolicyResourceTestHandler(gw *GatewayConfig, service, action, resource r := chi.NewRouter() r.Use(gw.SigV4AuthMiddleware()) r.HandleFunc("/*", func(w http.ResponseWriter, req *http.Request) { - if err := gw.checkPolicyResource(req, service, action, resource); err != nil { + if err := gw.checkPolicyResources(req, service, action, []string{resource}); err != nil { gw.ErrorHandler(w, req, err) return } diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index a5e1f329d..b2700ce8c 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -126,7 +126,7 @@ var ec2Actions = map[string]EC2Handler{ }), "RunInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResource(r, "iam", "PassRole", roleARN) + return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) } launchQuotaCheck := func() error { return gw.Quota.EnforceLaunch(ctx, accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount))) @@ -144,7 +144,7 @@ var ec2Actions = map[string]EC2Handler{ }), "AssociateIamInstanceProfile": ec2HandlerWithReq(func(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResource(r, "iam", "PassRole", roleARN) + return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) } return gateway_ec2_instance.AssociateIamInstanceProfile(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck) }), @@ -153,7 +153,7 @@ var ec2Actions = map[string]EC2Handler{ }), "ReplaceIamInstanceProfileAssociation": ec2HandlerWithReq(func(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResource(r, "iam", "PassRole", roleARN) + return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) } return gateway_ec2_instance.ReplaceIamInstanceProfileAssociation(ctx, input, gw.NATSConn, gw.IAMService, gw.DiscoverActiveNodes(ctx), accountID, passRoleCheck) }), @@ -399,7 +399,7 @@ var ec2Actions = map[string]EC2Handler{ }), "RequestSpotInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RequestSpotInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResource(r, "iam", "PassRole", roleARN) + return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) } return gateway_ec2_spotinstance.RequestSpotInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, gw.AZ, passRoleCheck, gw.Quota, gw.ExpectedNodes) }), diff --git a/spinifex/gateway/ecr_authorization.go b/spinifex/gateway/ecr_authorization.go index a8ff9c345..203cd20ba 100644 --- a/spinifex/gateway/ecr_authorization.go +++ b/spinifex/gateway/ecr_authorization.go @@ -53,7 +53,9 @@ func (gw *GatewayConfig) ecrOperationAuthorization(next http.Handler) http.Handl gateway_ecr.WriteError(w, http.StatusServiceUnavailable, "UNKNOWN", "authorization unavailable") return } - if err := gw.evaluatePrincipalPolicy(principal, policy.IAMAction("ecr", req.Action), resource); err != nil { + if err := gw.evaluatePrincipalPolicyResources( + principal, policy.IAMAction("ecr", req.Action), []string{resource}, + ); err != nil { if err.Error() == awserrors.ErrorInternalError { // IAM/STS/NATS dependency failure: fail closed, never dispatch. gateway_ecr.WriteError(w, http.StatusServiceUnavailable, "UNKNOWN", "authorization unavailable") diff --git a/spinifex/gateway/ecs.go b/spinifex/gateway/ecs.go index 7a1c5482e..b6e583251 100644 --- a/spinifex/gateway/ecs.go +++ b/spinifex/gateway/ecs.go @@ -56,7 +56,7 @@ func (gw *GatewayConfig) ECS_Request(w http.ResponseWriter, r *http.Request) err // enforce iam:PassRole against the caller's identity on this request, for // whichever role ARN the action later resolves. passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResource(r, "iam", "PassRole", roleARN) + return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) } output, err := handler(r.Context(), gw.NATSConn, accountID, body, passRoleCheck) diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 815d91698..457d72935 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -515,21 +515,15 @@ func isNATSTransient(err error) bool { } // checkPolicy evaluates IAM policies against resource "*". -// Shorthand for checkPolicyResource(r, service, action, "*"). func (gw *GatewayConfig) checkPolicy(r *http.Request, service, action string) error { - return gw.checkPolicyResource(r, service, action, "*") + return gw.checkPolicyResources(r, service, action, []string{"*"}) } -// checkPolicyResource evaluates IAM policies against a specific resource ARN. -// Root users bypass evaluation. A nil IAMService is a server fault and an -// unauthenticated request is denied — neither can reach a gated handler through -// the route tree. Used by EC2 paths that enforce iam:PassRole before attaching -// an instance profile. -func (gw *GatewayConfig) checkPolicyResource(r *http.Request, service, action, resource string) error { +// checkPolicyResources evaluates every resource against one resolved policy +// snapshot. Used when one API request authorizes multiple resource ARNs. +func (gw *GatewayConfig) checkPolicyResources(r *http.Request, service, action string, resources []string) error { // Every dispatcher — query-protocol and REST-JSON alike — reaches this - // point with its resolved action, so telemetry enrichment lives here - // rather than duplicated per REST-JSON handler. Runs before the IAM - // checks below so it still fires when IAM is unconfigured. + // point with its resolved action, so telemetry enrichment lives here. recordResolvedAction(r.Context(), service, action) if gw.IAMService == nil { @@ -566,7 +560,7 @@ func (gw *GatewayConfig) checkPolicyResource(r *http.Request, service, action, r assumedRoleID: mustCtxString(r, ctxAssumedRoleID), underlyingRoleARN: mustCtxString(r, ctxUnderlyingRoleARN), } - return gw.evaluatePrincipalPolicy(principal, policy.IAMAction(service, action), resource) + return gw.evaluatePrincipalPolicyResources(principal, policy.IAMAction(service, action), resources) } // mustCtxString reads a string context value, defaulting to "" for an absent @@ -576,12 +570,11 @@ func mustCtxString(r *http.Request, key contextKey) string { return v } -// evaluatePrincipalPolicy is the request-shape-independent core of policy -// enforcement: given an already-resolved principal (from SigV4 or, for /v2/* -// ECR requests, a freshly rehydrated token identity), it resolves the -// principal's current policies and evaluates iamAction against resource. -// A nil IAMService fails closed here, matching checkPolicyResource. -func (gw *GatewayConfig) evaluatePrincipalPolicy(principal principalContext, iamAction, resource string) error { +// evaluatePrincipalPolicyResources resolves policies once and evaluates every +// resource in the request against that same snapshot. +func (gw *GatewayConfig) evaluatePrincipalPolicyResources( + principal principalContext, iamAction string, resources []string, +) error { if gw.IAMService == nil { slog.Error("evaluatePrincipalPolicy: IAM service not available", "action", iamAction) return errors.New(awserrors.ErrorInternalError) @@ -644,11 +637,13 @@ func (gw *GatewayConfig) evaluatePrincipalPolicy(principal principalContext, iam return errors.New(awserrors.ErrorInternalError) } - if iampolicy.Evaluate(iamAction, resource, policies) == iampolicy.Deny { - slog.Info("evaluatePrincipalPolicy: access denied", "identity", logIdentity, "action", iamAction, "resource", resource) - return errors.New(awserrors.ErrorAccessDenied) + for _, resource := range resources { + if iampolicy.Evaluate(iamAction, resource, policies) == iampolicy.Deny { + slog.Info("evaluatePrincipalPolicy: access denied", + "identity", logIdentity, "action", iamAction, "resource", resource) + return errors.New(awserrors.ErrorAccessDenied) + } } - return nil } diff --git a/spinifex/gateway/gateway_test.go b/spinifex/gateway/gateway_test.go index 704c6ba34..1768d9983 100644 --- a/spinifex/gateway/gateway_test.go +++ b/spinifex/gateway/gateway_test.go @@ -1122,8 +1122,8 @@ func TestCheckPolicy_PassRoleFailsClosed(t *testing.T) { gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()} req := httptest.NewRequest(http.MethodPost, "/", nil) - err := gw.checkPolicyResource(req, "iam", "PassRole", - "arn:aws:iam::123456789012:role/app") + err := gw.checkPolicyResources(req, "iam", "PassRole", + []string{"arn:aws:iam::123456789012:role/app"}) require.Error(t, err) assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) } diff --git a/spinifex/gateway/rds.go b/spinifex/gateway/rds.go index b03b9e139..bbf5ff3f9 100644 --- a/spinifex/gateway/rds.go +++ b/spinifex/gateway/rds.go @@ -43,11 +43,11 @@ func (gw *GatewayConfig) RDS_Request(w http.ResponseWriter, r *http.Request) err return err } - resource, err := gateway_rds.ResourceARN(action, gw.Region, caller.AccountID, queryArgs) + resources, err := gateway_rds.ResourceARN(action, gw.Region, caller.AccountID, queryArgs) if err != nil { return err } - if err := gw.checkPolicyResource(r, "rds", action, resource); err != nil { + if err := gw.checkPolicyResources(r, "rds", action, resources); err != nil { return err } diff --git a/spinifex/gateway/rds/authz.go b/spinifex/gateway/rds/authz.go index 6cbe05111..cce1348ed 100644 --- a/spinifex/gateway/rds/authz.go +++ b/spinifex/gateway/rds/authz.go @@ -15,9 +15,8 @@ import ( // address. const anyResource = "*" -// Which query parameter names the single resource an action acts on, and the ARN -// type it resolves to. An action naming no resource, or more than one, carries no -// scope at all. +// Which query parameter names a resource an action acts on, and the ARN type it +// resolves to. Actions carry one scope for each resource IAM evaluates. type resourceScope struct { param string // Empty when param already carries a full ARN — the tag actions — which is @@ -62,33 +61,39 @@ func requireAgentPrincipal(ctx context.Context, caller Caller) error { return nil } -// ResourceARN builds the resource the action's policy check evaluates against. A +// ResourceARN builds every resource the action's policy checks evaluate. A // policy written for arn:aws:rds:*:*:db:prod-* but checked against "*" would // permit every instance in the account, which is worse than no policy at all. -func ResourceARN(action, region, accountID string, q map[string]string) (string, error) { +func ResourceARN(action, region, accountID string, q map[string]string) ([]string, error) { def, ok := actions[action] // Rejected here too, not just by the dispatcher: a caller that skipped the // action check must not get a resource back for rds:. if !ok { - return "", errors.New(awserrors.ErrorInvalidAction) + return nil, errors.New(awserrors.ErrorInvalidAction) } - if def.scope == nil { - return anyResource, nil + if len(def.scopes) == 0 { + return []string{anyResource}, nil } - identifier := q[def.scope.param] - // An absent identifier is a validation fault the handler reports precisely; - // answering it with a denial here would hide the real reason. - if identifier == "" { - return anyResource, nil - } - if def.scope.kind == "" { - // Parsed rather than passed through, so a foreign account or region is an - // InvalidParameterValue before the evaluator sees it: cross-account sharing - // is out of v1, and a permissive parser is how it arrives anyway. - if _, err := handlers_rds.ParseARN(identifier, region, accountID); err != nil { - return "", err + + resources := make([]string, 0, len(def.scopes)) + for _, scope := range def.scopes { + identifier := q[scope.param] + // A missing member contributes "*" independently. It remains the handler's + // validation fault rather than becoming an ARN resolution failure here. + if identifier == "" { + resources = append(resources, anyResource) + continue } - return identifier, nil + if scope.kind == "" { + // Parsed rather than passed through, so a foreign account or region is an + // InvalidParameterValue before the evaluator sees it. + if _, err := handlers_rds.ParseARN(identifier, region, accountID); err != nil { + return nil, err + } + resources = append(resources, identifier) + continue + } + resources = append(resources, handlers_rds.FormatARN(scope.kind, region, accountID, identifier)) } - return handlers_rds.FormatARN(def.scope.kind, region, accountID, identifier), nil + return resources, nil } diff --git a/spinifex/gateway/rds/authz_test.go b/spinifex/gateway/rds/authz_test.go index 780beca9f..8c58a09f2 100644 --- a/spinifex/gateway/rds/authz_test.go +++ b/spinifex/gateway/rds/authz_test.go @@ -108,13 +108,6 @@ func TestResourceARN_ScopesSingleResourceActions(t *testing.T) { "arn:aws:rds:ap-southeast-2:123456789012:db:orders-db"}, {"DeleteDBSnapshot", map[string]string{"DBSnapshotIdentifier": "orders-db-pre-upgrade"}, "arn:aws:rds:ap-southeast-2:123456789012:snapshot:orders-db-pre-upgrade"}, - // Both name a source and a target; each is scoped to its source. - {"CreateDBSnapshot", map[string]string{ - "DBInstanceIdentifier": "orders-db", "DBSnapshotIdentifier": "orders-db-pre-upgrade"}, - "arn:aws:rds:ap-southeast-2:123456789012:db:orders-db"}, - {"RestoreDBInstanceFromDBSnapshot", map[string]string{ - "DBInstanceIdentifier": "orders-db-restored", "DBSnapshotIdentifier": "orders-db-pre-upgrade"}, - "arn:aws:rds:ap-southeast-2:123456789012:snapshot:orders-db-pre-upgrade"}, {"DeleteDBSubnetGroup", map[string]string{"DBSubnetGroupName": "db-private"}, "arn:aws:rds:ap-southeast-2:123456789012:subgrp:db-private"}, {"ModifyDBParameterGroup", map[string]string{"DBParameterGroupName": "pg16-tuned"}, @@ -128,6 +121,32 @@ func TestResourceARN_ScopesSingleResourceActions(t *testing.T) { t.Run(tt.action, func(t *testing.T) { got, err := ResourceARN(tt.action, testRegion, testAccountID, tt.params) require.NoError(t, err) + assert.Equal(t, []string{tt.want}, got) + }) + } +} + +func TestResourceARN_ScopesSnapshotActionsToSourceAndTarget(t *testing.T) { + tests := []struct { + action string + want []string + }{ + {"CreateDBSnapshot", []string{ + "arn:aws:rds:ap-southeast-2:123456789012:db:orders-db", + "arn:aws:rds:ap-southeast-2:123456789012:snapshot:orders-db-pre-upgrade", + }}, + {"RestoreDBInstanceFromDBSnapshot", []string{ + "arn:aws:rds:ap-southeast-2:123456789012:snapshot:orders-db-pre-upgrade", + "arn:aws:rds:ap-southeast-2:123456789012:db:orders-db", + }}, + } + params := map[string]string{ + "DBInstanceIdentifier": "orders-db", "DBSnapshotIdentifier": "orders-db-pre-upgrade", + } + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + got, err := ResourceARN(tt.action, testRegion, testAccountID, params) + require.NoError(t, err) assert.Equal(t, tt.want, got) }) } @@ -153,7 +172,7 @@ func TestResourceARN_UnscopedActions(t *testing.T) { "DBSnapshotIdentifier": "orders-db-pre-upgrade", }) require.NoError(t, err) - assert.Equal(t, anyResource, got) + assert.Equal(t, []string{anyResource}, got) }) } } @@ -196,9 +215,15 @@ func TestResourceARN_ResourceScopedDenyReachesTheSnapshotActions(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resource, err := ResourceARN(tt.action, testRegion, testAccountID, tt.params) + resources, err := ResourceARN(tt.action, testRegion, testAccountID, tt.params) require.NoError(t, err) - assert.Equal(t, tt.want, iampolicy.Evaluate(policy.IAMAction("rds", tt.action), resource, policies)) + got := iampolicy.Allow + for _, resource := range resources { + if iampolicy.Evaluate(policy.IAMAction("rds", tt.action), resource, policies) == iampolicy.Deny { + got = iampolicy.Deny + } + } + assert.Equal(t, tt.want, got) }) } } @@ -211,7 +236,7 @@ func TestResourceARN_TagActionsUseTheSuppliedARN(t *testing.T) { t.Run(action, func(t *testing.T) { got, err := ResourceARN(action, testRegion, testAccountID, map[string]string{"ResourceName": arn}) require.NoError(t, err) - assert.Equal(t, arn, got) + assert.Equal(t, []string{arn}, got) }) } } @@ -237,16 +262,30 @@ func TestResourceARN_RejectsUnusableARNs(t *testing.T) { } } -// A missing identifier is the handler's validation fault to report. Answering it -// with a denial here would tell the caller the wrong thing about their policy. -func TestResourceARN_MissingIdentifierFallsBackToAnyResource(t *testing.T) { - for _, action := range []string{ - "DeleteDBInstance", "DescribeDBParameters", "ListTagsForResource", - "CreateDBSnapshot", "RestoreDBInstanceFromDBSnapshot", - } { - got, err := ResourceARN(action, testRegion, testAccountID, map[string]string{}) - require.NoError(t, err, "action %q", action) - assert.Equal(t, anyResource, got, "action %q", action) +// A missing identifier is the handler's validation fault to report. Each absent +// member contributes "*" instead of failing ARN resolution. +func TestResourceARN_MissingIdentifierFallsBackToAnyResourcePerMember(t *testing.T) { + tests := []struct { + action string + params map[string]string + want []string + }{ + {"DeleteDBInstance", nil, []string{anyResource}}, + {"DescribeDBParameters", nil, []string{anyResource}}, + {"ListTagsForResource", nil, []string{anyResource}}, + {"CreateDBSnapshot", map[string]string{"DBInstanceIdentifier": "orders-db"}, []string{ + "arn:aws:rds:ap-southeast-2:123456789012:db:orders-db", anyResource, + }}, + {"RestoreDBInstanceFromDBSnapshot", map[string]string{"DBSnapshotIdentifier": "orders-db-snapshot"}, []string{ + "arn:aws:rds:ap-southeast-2:123456789012:snapshot:orders-db-snapshot", anyResource, + }}, + } + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + got, err := ResourceARN(tt.action, testRegion, testAccountID, tt.params) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) } } diff --git a/spinifex/gateway/rds/handler.go b/spinifex/gateway/rds/handler.go index fb9d7c4f8..6974ce15e 100644 --- a/spinifex/gateway/rds/handler.go +++ b/spinifex/gateway/rds/handler.go @@ -80,8 +80,8 @@ func typed[In any](handler func(context.Context, *In, *nats.Conn, Caller) (any, } // One entry per action: what serves it, whether it is agent-only, and which -// resource its policy check evaluates against. Both authorization facts live on -// the table so a new action cannot be added without deciding either. +// resources its policy checks evaluate against. Both authorization facts live +// here so a new action cannot be added without deciding either. type actionDef struct { handler Handler // Agent-only: refused to every customer principal by class, before any @@ -92,7 +92,7 @@ type actionDef struct { unsupported bool // nil evaluates against "*", which is right for creates and for describes // that filter rather than address one resource. - scope *resourceScope + scopes []*resourceScope } // The whole namespace is registered from day one, so an action outside v1 stays @@ -101,19 +101,20 @@ var actions = map[string]actionDef{ // Instance lifecycle. "CreateDBInstance": {handler: typedEnv(CreateDBInstance)}, "DescribeDBInstances": {handler: typed(DescribeDBInstances)}, - "ModifyDBInstance": {handler: typed(ModifyDBInstance), scope: dbInstanceScope}, - "DeleteDBInstance": {handler: typed(DeleteDBInstance), scope: dbInstanceScope}, - "RebootDBInstance": {handler: typed(RebootDBInstance), scope: dbInstanceScope}, - "StartDBInstance": {handler: typed(StartDBInstance), scope: dbInstanceScope}, - "StopDBInstance": {handler: typed(StopDBInstance), scope: dbInstanceScope}, + "ModifyDBInstance": {handler: typed(ModifyDBInstance), scopes: []*resourceScope{dbInstanceScope}}, + "DeleteDBInstance": {handler: typed(DeleteDBInstance), scopes: []*resourceScope{dbInstanceScope}}, + "RebootDBInstance": {handler: typed(RebootDBInstance), scopes: []*resourceScope{dbInstanceScope}}, + "StartDBInstance": {handler: typed(StartDBInstance), scopes: []*resourceScope{dbInstanceScope}}, + "StopDBInstance": {handler: typed(StopDBInstance), scopes: []*resourceScope{dbInstanceScope}}, - // Snapshots. A create and a restore each name two resources, and both are - // scoped to the source: a deny written on an instance has to stop it being - // snapshotted, and a deny on a snapshot has to stop it being restored. - "CreateDBSnapshot": {handler: typed(CreateDBSnapshot), scope: dbInstanceScope}, - "DescribeDBSnapshots": {handler: typed(DescribeDBSnapshots)}, - "DeleteDBSnapshot": {handler: typed(DeleteDBSnapshot), scope: dbSnapshotScope}, - "RestoreDBInstanceFromDBSnapshot": {handler: typed(RestoreDBInstanceFromDBSnapshot), scope: dbSnapshotScope}, + // Snapshot creates and restores evaluate both the source and target. A deny + // on either resource must block the operation. + "CreateDBSnapshot": {handler: typed(CreateDBSnapshot), + scopes: []*resourceScope{dbInstanceScope, dbSnapshotScope}}, + "DescribeDBSnapshots": {handler: typed(DescribeDBSnapshots)}, + "DeleteDBSnapshot": {handler: typed(DeleteDBSnapshot), scopes: []*resourceScope{dbSnapshotScope}}, + "RestoreDBInstanceFromDBSnapshot": {handler: typed(RestoreDBInstanceFromDBSnapshot), + scopes: []*resourceScope{dbSnapshotScope, dbInstanceScope}}, // Automated backups. "DescribeDBInstanceAutomatedBackups": {handler: typed(DescribeDBInstanceAutomatedBackups)}, @@ -121,21 +122,24 @@ var actions = map[string]actionDef{ // Subnet groups. "CreateDBSubnetGroup": {handler: typed(CreateDBSubnetGroup)}, "DescribeDBSubnetGroups": {handler: typed(DescribeDBSubnetGroups)}, - "DeleteDBSubnetGroup": {handler: typed(DeleteDBSubnetGroup), scope: dbSubnetGroupScope}, + "DeleteDBSubnetGroup": {handler: typed(DeleteDBSubnetGroup), scopes: []*resourceScope{dbSubnetGroupScope}}, // Parameter groups. DescribeDBParameters is scoped despite being a describe: // its parameter group is required and singular, so it addresses one resource. "CreateDBParameterGroup": {handler: typed(CreateDBParameterGroup)}, "DescribeDBParameterGroups": {handler: typed(DescribeDBParameterGroups)}, - "ModifyDBParameterGroup": {handler: typed(ModifyDBParameterGroup), scope: dbParameterGroupScope}, - "DescribeDBParameters": {handler: typed(DescribeDBParameters), scope: dbParameterGroupScope}, - "DeleteDBParameterGroup": {handler: typed(DeleteDBParameterGroup), scope: dbParameterGroupScope}, + "ModifyDBParameterGroup": {handler: typed(ModifyDBParameterGroup), + scopes: []*resourceScope{dbParameterGroupScope}}, + "DescribeDBParameters": {handler: typed(DescribeDBParameters), + scopes: []*resourceScope{dbParameterGroupScope}}, + "DeleteDBParameterGroup": {handler: typed(DeleteDBParameterGroup), + scopes: []*resourceScope{dbParameterGroupScope}}, // Tags. The request names its resource by ARN, so the scope validates one // rather than building it. - "AddTagsToResource": {handler: typed(AddTagsToResource), scope: taggedResourceScope}, - "RemoveTagsFromResource": {handler: typed(RemoveTagsFromResource), scope: taggedResourceScope}, - "ListTagsForResource": {handler: typed(ListTagsForResource), scope: taggedResourceScope}, + "AddTagsToResource": {handler: typed(AddTagsToResource), scopes: []*resourceScope{taggedResourceScope}}, + "RemoveTagsFromResource": {handler: typed(RemoveTagsFromResource), scopes: []*resourceScope{taggedResourceScope}}, + "ListTagsForResource": {handler: typed(ListTagsForResource), scopes: []*resourceScope{taggedResourceScope}}, // Events. The ring is per-account and a filter names no single resource. "DescribeEvents": {handler: typed(DescribeEvents)}, diff --git a/spinifex/gateway/rds_test.go b/spinifex/gateway/rds_test.go index 0bbeda38b..69bab1982 100644 --- a/spinifex/gateway/rds_test.go +++ b/spinifex/gateway/rds_test.go @@ -192,6 +192,110 @@ func TestRDSRequest_ResourceScopedPolicyRestrictsByIdentifier(t *testing.T) { assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) } +func TestRDSRequest_SnapshotActionsAuthorizeSourceAndTarget(t *testing.T) { + tests := []struct { + name string + action string + query string + resources handlers_iam.StringOrArr + }{ + { + name: "create snapshot", + action: "rds:CreateDBSnapshot", + query: "Action=CreateDBSnapshot&DBInstanceIdentifier=orders-db" + + "&DBSnapshotIdentifier=orders-db-nightly", + resources: handlers_iam.StringOrArr{ + handlers_rds.DBInstanceARN("ap-southeast-2", rdsTestAccountID, "orders-db"), + handlers_rds.DBSnapshotARN("ap-southeast-2", rdsTestAccountID, "orders-db-nightly"), + }, + }, + { + name: "restore snapshot", + action: "rds:RestoreDBInstanceFromDBSnapshot", + query: "Action=RestoreDBInstanceFromDBSnapshot&DBSnapshotIdentifier=orders-db-nightly" + + "&DBInstanceIdentifier=orders-db-restored", + resources: handlers_iam.StringOrArr{ + handlers_rds.DBSnapshotARN("ap-southeast-2", rdsTestAccountID, "orders-db-nightly"), + handlers_rds.DBInstanceARN("ap-southeast-2", rdsTestAccountID, "orders-db-restored"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policies := []handlers_iam.PolicyDocument{{ + Version: "2012-10-17", + Statement: []handlers_iam.Statement{{ + Effect: "Allow", Action: handlers_iam.StringOrArr{tt.action}, Resource: tt.resources, + }}, + }} + gw, probe := newRDSPolicyGateway(policies) + err := gw.RDS_Request(httptest.NewRecorder(), setupRDSUserRequest(tt.query, rdsTestAccountID)) + require.Error(t, err) + assert.Equal(t, 1, probe.consulted) + assert.Equal(t, awserrors.ErrorServerInternal, err.Error()) + }) + } +} + +func TestRDSRequest_SnapshotActionsUseOnePolicySnapshot(t *testing.T) { + source := handlers_rds.DBInstanceARN("ap-southeast-2", rdsTestAccountID, "orders-db") + target := handlers_rds.DBSnapshotARN("ap-southeast-2", rdsTestAccountID, "orders-db-nightly") + resolution := 0 + gw, probe := newRDSGatewayWithResolver(func(_, _ string) ([]handlers_iam.PolicyDocument, error) { + resolution++ + if resolution == 1 { + return allowPolicyResource("rds:CreateDBSnapshot", source), nil + } + return allowPolicyResource("rds:CreateDBSnapshot", target), nil + }) + + err := gw.RDS_Request(httptest.NewRecorder(), setupRDSUserRequest( + "Action=CreateDBSnapshot&DBInstanceIdentifier=orders-db&DBSnapshotIdentifier=orders-db-nightly", + rdsTestAccountID, + )) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) + assert.Equal(t, 1, probe.consulted) +} + +func TestRDSRequest_SnapshotActionsHonorTargetDeny(t *testing.T) { + tests := []struct { + name string + action string + query string + target string + }{ + { + name: "create snapshot", action: "rds:CreateDBSnapshot", + query: "Action=CreateDBSnapshot&DBInstanceIdentifier=orders-db" + + "&DBSnapshotIdentifier=orders-db-nightly", + target: handlers_rds.DBSnapshotARN("ap-southeast-2", rdsTestAccountID, "orders-db-nightly"), + }, + { + name: "restore snapshot", action: "rds:RestoreDBInstanceFromDBSnapshot", + query: "Action=RestoreDBInstanceFromDBSnapshot&DBSnapshotIdentifier=orders-db-nightly" + + "&DBInstanceIdentifier=orders-db-restored", + target: handlers_rds.DBInstanceARN("ap-southeast-2", rdsTestAccountID, "orders-db-restored"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policies := []handlers_iam.PolicyDocument{{ + Version: "2012-10-17", + Statement: []handlers_iam.Statement{ + {Effect: "Allow", Action: handlers_iam.StringOrArr{tt.action}, Resource: handlers_iam.StringOrArr{"*"}}, + {Effect: "Deny", Action: handlers_iam.StringOrArr{tt.action}, Resource: handlers_iam.StringOrArr{tt.target}}, + }, + }} + gw, probe := newRDSPolicyGateway(policies) + err := gw.RDS_Request(httptest.NewRecorder(), setupRDSUserRequest(tt.query, rdsTestAccountID)) + require.Error(t, err) + assert.Equal(t, 1, probe.consulted) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) + }) + } +} + // Creates and plural describes name no one resource, so a policy scoped to a // single instance must not be read as permitting them. func TestRDSRequest_UnscopedActionsEvaluateAgainstAnyResource(t *testing.T) { diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go index de32a33d9..e1a1f6c5f 100644 --- a/spinifex/handlers/ec2/instance/service_impl.go +++ b/spinifex/handlers/ec2/instance/service_impl.go @@ -716,74 +716,69 @@ func (s *InstanceServiceImpl) PrepareRunInstances(ctx context.Context, input *ec "mac", instance.ENIMac, ) - if s.ipAllocator != nil { - subnet, subErr := s.eniCreator.GetSubnet(ctx, accountID, *input.SubnetId) - wantPublic := subErr == nil && subnet != nil && subnet.MapPublicIpOnLaunch - if len(input.NetworkInterfaces) > 0 && input.NetworkInterfaces[0] != nil && input.NetworkInterfaces[0].AssociatePublicIpAddress != nil { - wantPublic = *input.NetworkInterfaces[0].AssociatePublicIpAddress - } - if wantPublic { - region := s.config.Region - az := s.config.AZ - publicIP, poolName, allocErr := s.ipAllocator.AllocateIP(ctx, region, az, handlers_ec2_vpc.PurposeENIPublic, "", *eni.NetworkInterfaceId, instance.ID) - if allocErr != nil { - // Fail rather than boot an unreachable instance; - // detach before delete since in-use ENIs reject deletion. - slog.ErrorContext(ctx, "PrepareRunInstances: public IP allocation failed — aborting launch", - "instanceId", instance.ID, "eniId", *eni.NetworkInterfaceId, "err", allocErr) - if detErr := s.eniCreator.DetachENI(ctx, accountID, *eni.NetworkInterfaceId); detErr != nil { - slog.WarnContext(ctx, "PrepareRunInstances: failed to detach ENI after public-IP allocation failure", - "eniId", *eni.NetworkInterfaceId, "err", detErr) - } - if _, delErr := s.eniDeleter.DeleteNetworkInterface(ctx, &ec2.DeleteNetworkInterfaceInput{ - NetworkInterfaceId: eni.NetworkInterfaceId, - }, accountID); delErr != nil { - slog.WarnContext(ctx, "PrepareRunInstances: failed to delete ENI after public-IP allocation failure", - "eniId", *eni.NetworkInterfaceId, "err", delErr) - } - // Carry the allocator's own error rather than a flat - // capacity code: the gateway resolves the AWS code out - // of the wrap chain, so a genuinely exhausted pool still - // surfaces InsufficientAddressCapacity while an upstream - // DHCP or IPAM fault reports as itself instead of - // wearing a capacity code it did not earn. - lastRunErr = fmt.Errorf("allocate public IP for %s: %w", instance.ID, allocErr) - if reservationID == "" { - s.resourceMgr.Deallocate(instanceType) - } else { - s.resourceMgr.ReleaseToReservation(reservationID, instanceType) - } - continue - } else { - if updateErr := s.eniCreator.UpdateENIPublicIP(ctx, accountID, *eni.NetworkInterfaceId, publicIP, poolName); updateErr != nil { - slog.WarnContext(ctx, "PrepareRunInstances: failed to update ENI with public IP", "eniId", *eni.NetworkInterfaceId, "err", updateErr) - } - portName := topology.Port(*eni.NetworkInterfaceId) - if natErr := utils.AddNAT(s.natsConn, *eni.VpcId, publicIP, *eni.PrivateIpAddress, portName, *eni.MacAddress); natErr != nil { - slog.ErrorContext(ctx, "PrepareRunInstances: vpc.add-nat failed — rolling back public IP to avoid surfacing an unreachable address", - "instanceId", instance.ID, "publicIp", publicIP, "pool", poolName, "err", natErr) - // Neutralise before releasing in case timeout committed the rule. - utils.PublishNATEvent(s.natsConn, "vpc.delete-nat", *eni.VpcId, publicIP, *eni.PrivateIpAddress, portName, *eni.MacAddress) - s.rollbackAutoAssignedPublicIP(ctx, accountID, instance.ID, *eni.NetworkInterfaceId, publicIP, poolName) - lastRunErr = natErr - if reservationID == "" { - s.resourceMgr.Deallocate(instanceType) - } else { - s.resourceMgr.ReleaseToReservation(reservationID, instanceType) - } - continue - } - ec2Instance.PublicIpAddress = aws.String(publicIP) - instance.PublicIP = publicIP - instance.PublicIPPool = poolName - slog.InfoContext(ctx, "PrepareRunInstances: auto-assigned public IP", - "instanceId", instance.ID, - "publicIp", publicIP, - "privateIp", *eni.PrivateIpAddress, - "pool", poolName, - ) + subnet, subErr := s.eniCreator.GetSubnet(ctx, accountID, *input.SubnetId) + wantPublic := subErr == nil && subnet != nil && subnet.MapPublicIpOnLaunch + if len(input.NetworkInterfaces) > 0 && input.NetworkInterfaces[0] != nil && input.NetworkInterfaces[0].AssociatePublicIpAddress != nil { + wantPublic = *input.NetworkInterfaces[0].AssociatePublicIpAddress + } + if wantPublic { + publicIP, poolName, allocErr := s.allocatePublicIP(ctx, *eni.NetworkInterfaceId, instance.ID) + if allocErr != nil { + // Fail rather than boot an unreachable instance; + // detach before delete since in-use ENIs reject deletion. + slog.ErrorContext(ctx, "PrepareRunInstances: public IP allocation failed — aborting launch", + "instanceId", instance.ID, "eniId", *eni.NetworkInterfaceId, "err", allocErr) + if detErr := s.eniCreator.DetachENI(ctx, accountID, *eni.NetworkInterfaceId); detErr != nil { + slog.WarnContext(ctx, "PrepareRunInstances: failed to detach ENI after public-IP allocation failure", + "eniId", *eni.NetworkInterfaceId, "err", detErr) } + if _, delErr := s.eniDeleter.DeleteNetworkInterface(ctx, &ec2.DeleteNetworkInterfaceInput{ + NetworkInterfaceId: eni.NetworkInterfaceId, + }, accountID); delErr != nil { + slog.WarnContext(ctx, "PrepareRunInstances: failed to delete ENI after public-IP allocation failure", + "eniId", *eni.NetworkInterfaceId, "err", delErr) + } + // Carry the allocator's own error rather than a flat + // capacity code: the gateway resolves the AWS code out + // of the wrap chain, so a genuinely exhausted pool still + // surfaces InsufficientAddressCapacity while an upstream + // DHCP or IPAM fault reports as itself instead of + // wearing a capacity code it did not earn. + lastRunErr = fmt.Errorf("allocate public IP for %s: %w", instance.ID, allocErr) + if reservationID == "" { + s.resourceMgr.Deallocate(instanceType) + } else { + s.resourceMgr.ReleaseToReservation(reservationID, instanceType) + } + continue } + if updateErr := s.eniCreator.UpdateENIPublicIP(ctx, accountID, *eni.NetworkInterfaceId, publicIP, poolName); updateErr != nil { + slog.WarnContext(ctx, "PrepareRunInstances: failed to update ENI with public IP", "eniId", *eni.NetworkInterfaceId, "err", updateErr) + } + portName := topology.Port(*eni.NetworkInterfaceId) + if natErr := utils.AddNAT(s.natsConn, *eni.VpcId, publicIP, *eni.PrivateIpAddress, portName, *eni.MacAddress); natErr != nil { + slog.ErrorContext(ctx, "PrepareRunInstances: vpc.add-nat failed — rolling back public IP to avoid surfacing an unreachable address", + "instanceId", instance.ID, "publicIp", publicIP, "pool", poolName, "err", natErr) + // Neutralise before releasing in case timeout committed the rule. + utils.PublishNATEvent(s.natsConn, "vpc.delete-nat", *eni.VpcId, publicIP, *eni.PrivateIpAddress, portName, *eni.MacAddress) + s.rollbackAutoAssignedPublicIP(ctx, accountID, instance.ID, *eni.NetworkInterfaceId, publicIP, poolName) + lastRunErr = natErr + if reservationID == "" { + s.resourceMgr.Deallocate(instanceType) + } else { + s.resourceMgr.ReleaseToReservation(reservationID, instanceType) + } + continue + } + ec2Instance.PublicIpAddress = aws.String(publicIP) + instance.PublicIP = publicIP + instance.PublicIPPool = poolName + slog.InfoContext(ctx, "PrepareRunInstances: auto-assigned public IP", + "instanceId", instance.ID, + "publicIp", publicIP, + "privateIp", *eni.PrivateIpAddress, + "pool", poolName, + ) } } @@ -2508,6 +2503,22 @@ func (s *InstanceServiceImpl) deleteInstanceVolumes(ctx context.Context, instanc _ = instanceID } +// allocatePublicIP allocates an auto-assigned public IP for a new ENI. A node +// whose external IPAM never initialised has no allocator wired: report no +// capacity so the caller aborts rather than booting an unreachable instance. +func (s *InstanceServiceImpl) allocatePublicIP(ctx context.Context, eniID, instanceID string) (string, string, error) { + if s.ipAllocator == nil { + return "", "", fmt.Errorf("no public IP allocator on this node: %w", errors.New(awserrors.ErrorInsufficientAddressCapacity)) + } + region := "" + az := "" + if s.config != nil { + region = s.config.Region + az = s.config.AZ + } + return s.ipAllocator.AllocateIP(ctx, region, az, handlers_ec2_vpc.PurposeENIPublic, "", eniID, instanceID) +} + // rollbackAutoAssignedPublicIP unwinds a failed auto-assign: clears the ENI // public IP record, releases the IPAM lease, then detaches and deletes the ENI. func (s *InstanceServiceImpl) rollbackAutoAssignedPublicIP(ctx context.Context, accountID, instanceID, eniID, publicIP, poolName string) { diff --git a/spinifex/handlers/ec2/instance/service_impl_test.go b/spinifex/handlers/ec2/instance/service_impl_test.go index a2f2ffa4f..6e4266165 100644 --- a/spinifex/handlers/ec2/instance/service_impl_test.go +++ b/spinifex/handlers/ec2/instance/service_impl_test.go @@ -16,6 +16,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/ebsmetadata" "github.com/mulgadc/spinifex/spinifex/gpu" + handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/spinifex/tags" "github.com/mulgadc/spinifex/spinifex/testutil" spxtypes "github.com/mulgadc/spinifex/spinifex/types" @@ -3165,6 +3166,55 @@ func TestPrepareRunInstances_PublicIPAutoAssigned(t *testing.T) { } } +// TestPrepareRunInstances_PublicIPWithoutAllocator covers a node whose external +// IPAM never initialised. A nil *ExternalIPAM in the interface reads as non-nil, +// so the launch used to deref a nil receiver and take the daemon down with the +// ENI already persisted; both wirings must now fail closed and unwind the ENI. +func TestPrepareRunInstances_PublicIPWithoutAllocator(t *testing.T) { + cases := []struct { + name string + ipam PublicIPAllocator + }{ + {name: "typed_nil_external_ipam", ipam: (*handlers_ec2_vpc.ExternalIPAM)(nil)}, + {name: "no_allocator_wired", ipam: nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + eni := &fakeENICreator{ + subnet: &SubnetInfo{SubnetID: "subnet-1", VpcID: "vpc-1", MapPublicIpOnLaunch: true}, + createOut: &ec2.CreateNetworkInterfaceOutput{ + NetworkInterface: &ec2.NetworkInterface{ + NetworkInterfaceId: aws.String("eni-no-ipam"), + MacAddress: aws.String("aa:bb:cc:dd:ee:11"), + PrivateIpAddress: aws.String("10.0.0.40"), + VpcId: aws.String("vpc-1"), + }, + }, + } + deleter := &fakeENIDeleter{} + svc, prov := prepareSvcWithENI(t, eni, nil) + svc.eniDeleter = deleter + svc.SetRunInstancesDeps(svc.amiLoader, nil, eni, tc.ipam) + + _, instances, _, err := svc.PrepareRunInstances(context.Background(), &ec2.RunInstancesInput{ + InstanceType: aws.String("t3.micro"), + ImageId: aws.String("ami-1"), + SubnetId: aws.String("subnet-1"), + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + }, "acc", "") + + require.Error(t, err, "a public-IP launch with no allocator must fail, not boot an instance with no public address") + assert.Equal(t, awserrors.ErrorInsufficientAddressCapacity, err.Error()) + assert.Empty(t, instances) + assert.Equal(t, 1, eni.detachCalls, "ENI must be detached before delete") + assert.Equal(t, []string{"eni-no-ipam"}, deleter.calls, + "the auto-created ENI must be deleted, otherwise it strands and blocks security group deletion") + assert.Len(t, prov.deallocated, 1, "capacity must be returned when the launch aborts") + }) + } +} + // TestPrepareRunInstances_NATFailureRollsBackPublicIP verifies that a vpc.add-nat // failure drops the instance, clears the ENI public IP, releases the IPAM // lease, and deallocates capacity. diff --git a/spinifex/handlers/ec2/vpc/external_ipam.go b/spinifex/handlers/ec2/vpc/external_ipam.go index b15b0a4b8..bececbc3c 100644 --- a/spinifex/handlers/ec2/vpc/external_ipam.go +++ b/spinifex/handlers/ec2/vpc/external_ipam.go @@ -79,7 +79,12 @@ func (m *ExternalIPAM) EnableDHCP(client *dhcp.NATSClient) error { // AllocateIP allocates the next available external IP from the best pool // matching the given region/AZ. Returns the allocated IP and pool name. +// Nil-receiver tolerant: a node whose IPAM never initialised reports no +// capacity rather than panicking through a typed-nil interface. func (m *ExternalIPAM) AllocateIP(ctx context.Context, region, az, purpose, allocID, eniID, instanceID string) (string, string, error) { + if m == nil { + return "", "", fmt.Errorf("external IPAM unavailable on this node: %w", errors.New(awserrors.ErrorInsufficientAddressCapacity)) + } pool := m.findPool(region, az) if pool == nil { return "", "", fmt.Errorf("no external pool available for region=%q az=%q: %w", region, az, errors.New(awserrors.ErrorInsufficientAddressCapacity)) @@ -113,7 +118,11 @@ func (m *ExternalIPAM) AllocateFromPool(ctx context.Context, poolName, purpose, // ReleaseIP releases a previously allocated external IP back to its pool. // ownerENIID, when non-empty, scopes the release to the ENI that currently owns // the lease so a stale or duplicated teardown for a recycled IP is a no-op. +// Nil-receiver tolerant for the same reason as AllocateIP. func (m *ExternalIPAM) ReleaseIP(ctx context.Context, poolName, ip, ownerENIID string) error { + if m == nil { + return errors.New("external IPAM unavailable on this node") + } addr, err := netip.ParseAddr(ip) if err != nil { return fmt.Errorf("parse release IP %q: %w", ip, err) diff --git a/spinifex/handlers/eks/addon_catalog.go b/spinifex/handlers/eks/addon_catalog.go index aea0965ee..29980b02b 100644 --- a/spinifex/handlers/eks/addon_catalog.go +++ b/spinifex/handlers/eks/addon_catalog.go @@ -1,6 +1,9 @@ package handlers_eks -import "slices" +import ( + "fmt" + "slices" +) // nvidiaDevicePluginAddonName is auto-staged on GPU nodegroup create (see // stageGPUDeviceAddon in nodegroup.go), never user-requested directly. @@ -57,18 +60,18 @@ func hiddenAddonSpec(s AddonSpec) AddonSpec { return s } -// newAddonSpec builds a spec with DefaultVersion = versions[0]. Panics on empty versions. +// newAddonSpec builds a spec with the first version as its default. func newAddonSpec(name string, requiresIRSA bool, description string, versions ...string) AddonSpec { - if len(versions) == 0 { - panic("eks: addon spec " + name + " has no versions") + spec := AddonSpec{ + Name: name, + Versions: versions, + RequiresIRSA: requiresIRSA, + Description: description, } - return AddonSpec{ - Name: name, - Versions: versions, - DefaultVersion: versions[0], - RequiresIRSA: requiresIRSA, - Description: description, + if len(versions) > 0 { + spec.DefaultVersion = versions[0] } + return spec } // buildAddonCatalog indexes the specs by name. @@ -80,6 +83,21 @@ func buildAddonCatalog(specs ...AddonSpec) map[string]AddonSpec { return out } +func validateAddonCatalog(catalog map[string]AddonSpec) error { + for name, spec := range catalog { + if spec.Name != name { + return fmt.Errorf("add-on catalog key %q does not match spec name %q", name, spec.Name) + } + if len(spec.Versions) == 0 { + return fmt.Errorf("add-on %q has no versions", name) + } + if !spec.supportsVersion(spec.DefaultVersion) { + return fmt.Errorf("add-on %q default version %q is not supported", name, spec.DefaultVersion) + } + } + return nil +} + // lookupAddon returns the spec for name and whether it is in the catalog. func lookupAddon(name string) (AddonSpec, bool) { spec, ok := addonCatalog[name] diff --git a/spinifex/handlers/eks/addon_catalog_test.go b/spinifex/handlers/eks/addon_catalog_test.go index 12fb1a7ee..04c328300 100644 --- a/spinifex/handlers/eks/addon_catalog_test.go +++ b/spinifex/handlers/eks/addon_catalog_test.go @@ -40,8 +40,59 @@ func TestCatalogSpecs_SortedByName(t *testing.T) { } } -func TestNewAddonSpec_PanicsWithoutVersions(t *testing.T) { - assert.Panics(t, func() { - newAddonSpec("broken", false, "no versions") - }) +func TestValidateAddonCatalog(t *testing.T) { + tests := []struct { + name string + catalog map[string]AddonSpec + wantErr string + }{ + { + name: "valid catalog", + catalog: buildAddonCatalog(newAddonSpec("valid", false, "valid add-on", "1.0.0")), + }, + { + name: "no versions", + catalog: buildAddonCatalog(newAddonSpec("broken", false, "no versions")), + wantErr: `add-on "broken" has no versions`, + }, + { + name: "mismatched name", + catalog: map[string]AddonSpec{ + "catalog-name": newAddonSpec("spec-name", false, "mismatched name", "1.0.0"), + }, + wantErr: `add-on catalog key "catalog-name" does not match spec name "spec-name"`, + }, + { + name: "unsupported default", + catalog: map[string]AddonSpec{ + "broken": { + Name: "broken", + Versions: []string{"1.0.0"}, + DefaultVersion: "2.0.0", + }, + }, + wantErr: `add-on "broken" default version "2.0.0" is not supported`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAddonCatalog(tt.catalog) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestNewEKSServiceImpl_RejectsInvalidAddonCatalog(t *testing.T) { + previousCatalog := addonCatalog + addonCatalog = buildAddonCatalog(newAddonSpec("broken", false, "no versions")) + t.Cleanup(func() { addonCatalog = previousCatalog }) + + svc, err := NewEKSServiceImpl(EKSServiceDeps{}) + require.EqualError(t, err, `eks: validate add-on catalog: add-on "broken" has no versions`) + assert.Nil(t, svc) } diff --git a/spinifex/handlers/eks/service_impl.go b/spinifex/handlers/eks/service_impl.go index ba2435b6e..79eb287c8 100644 --- a/spinifex/handlers/eks/service_impl.go +++ b/spinifex/handlers/eks/service_impl.go @@ -289,6 +289,9 @@ const defaultK8sVersion = "1.32" // NewEKSServiceImpl initialises EKSServiceImpl, wiring the leader KV and reconciler registry. func NewEKSServiceImpl(deps EKSServiceDeps) (*EKSServiceImpl, error) { + if err := validateAddonCatalog(addonCatalog); err != nil { + return nil, fmt.Errorf("eks: validate add-on catalog: %w", err) + } if deps.NATSConn == nil { return nil, errors.New("eks: NewEKSServiceImpl nil NATSConn") } diff --git a/spinifex/handlers/iam/managed_policies.go b/spinifex/handlers/iam/managed_policies.go index 8e47d50bb..0eeee068c 100644 --- a/spinifex/handlers/iam/managed_policies.go +++ b/spinifex/handlers/iam/managed_policies.go @@ -282,18 +282,20 @@ var awsManagedPolicyDocs = map[string]string{ } // builtinManagedPolicyParsed is the parsed form of awsManagedPolicyDocs, built -// once at init. A malformed builtin document is a programming error and panics. -var builtinManagedPolicyParsed = func() map[string]PolicyDocument { - parsed := make(map[string]PolicyDocument, len(awsManagedPolicyDocs)) - for arn, raw := range awsManagedPolicyDocs { +// once at init. NewIAMServiceImpl propagates any parsing failure. +var builtinManagedPolicyParsed, builtinManagedPolicyParseErr = parseBuiltinManagedPolicies(awsManagedPolicyDocs) + +func parseBuiltinManagedPolicies(rawPolicies map[string]string) (map[string]PolicyDocument, error) { + parsed := make(map[string]PolicyDocument, len(rawPolicies)) + for arn, raw := range rawPolicies { var doc PolicyDocument if err := json.Unmarshal([]byte(raw), &doc); err != nil { - panic(fmt.Sprintf("iam: malformed builtin managed policy %s: %v", arn, err)) + return nil, fmt.Errorf("parse managed policy %s: %w", arn, err) } parsed[arn] = doc } - return parsed -}() + return parsed, nil +} // builtinManagedPolicyDoc returns the modeled grant document for an AWS-managed // policy ARN. ok is false for managed ARNs Spinifex does not model. diff --git a/spinifex/handlers/iam/managed_policies_test.go b/spinifex/handlers/iam/managed_policies_test.go index 29f18d032..2fca85ba6 100644 --- a/spinifex/handlers/iam/managed_policies_test.go +++ b/spinifex/handlers/iam/managed_policies_test.go @@ -1,6 +1,7 @@ package handlers_iam import ( + "errors" "testing" "github.com/mulgadc/bluebottle/pkg/iampolicy" @@ -22,6 +23,52 @@ var rdsInternalActions = []string{ "rds:GetDBBootstrapConfig", } +func TestParseBuiltinManagedPolicies(t *testing.T) { + tests := []struct { + name string + raw map[string]string + wantErr string + }{ + { + name: "valid document", + raw: map[string]string{ + "arn:valid": `{"Version":"2012-10-17","Statement":[]}`, + }, + }, + { + name: "malformed document", + raw: map[string]string{ + "arn:malformed": `{`, + }, + wantErr: "parse managed policy arn:malformed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed, err := parseBuiltinManagedPolicies(tt.raw) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, parsed) + return + } + require.NoError(t, err) + assert.Len(t, parsed, len(tt.raw)) + }) + } +} + +func TestNewIAMServiceImpl_PropagatesBuiltinManagedPolicyParseError(t *testing.T) { + parseErr := errors.New("malformed builtin policy") + previousErr := builtinManagedPolicyParseErr + builtinManagedPolicyParseErr = parseErr + t.Cleanup(func() { builtinManagedPolicyParseErr = previousErr }) + + _, err := NewIAMServiceImpl(t.Context(), nil, make([]byte, 32), 1) + require.ErrorIs(t, err, parseErr) + assert.ErrorContains(t, err, "init builtin managed policies") +} + // A role whose permissions come entirely from an attached AWS-managed ARN has to // resolve to a grant, the way the stock EKS roles already do, rather than fail // closed on a document Spinifex never stores. diff --git a/spinifex/handlers/iam/mock/mock.go b/spinifex/handlers/iam/mock/mock.go index 93f1c0fab..6ba742004 100644 --- a/spinifex/handlers/iam/mock/mock.go +++ b/spinifex/handlers/iam/mock/mock.go @@ -4,6 +4,7 @@ package mock import ( "errors" + "sync" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" @@ -15,8 +16,13 @@ import ( // handlers_iam.SystemInstanceRoleEnsurer. Roles and instance profiles are // stored keyed by name; Get* miss with NoSuchEntity until the matching // Create* call, and a repeat Create* reports EntityAlreadyExists, mirroring -// the live IAM API. +// the live IAM API. Every method is safe for concurrent use, so callers that +// converge on one profile from several goroutines can be exercised under the +// race detector; exported fields must only be touched while no call is in +// flight. type SystemInstanceRoleEnsurer struct { + mu sync.Mutex + Roles map[string]*iam.Role Profiles map[string]*iam.InstanceProfile @@ -64,15 +70,35 @@ func New() *SystemInstanceRoleEnsurer { } } +// copyRole and copyProfile hand callers a snapshot rather than a pointer into +// the mock's state, so a concurrent AddRoleToInstanceProfile cannot mutate a +// row another goroutine is still reading. +func copyRole(r *iam.Role) *iam.Role { + c := *r + return &c +} + +func copyProfile(p *iam.InstanceProfile) *iam.InstanceProfile { + c := *p + c.Roles = append([]*iam.Role(nil), p.Roles...) + return &c +} + func (e *SystemInstanceRoleEnsurer) GetRole(_ string, in *iam.GetRoleInput) (*iam.GetRoleOutput, error) { + e.mu.Lock() + defer e.mu.Unlock() + r, ok := e.Roles[aws.StringValue(in.RoleName)] if !ok { return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) } - return &iam.GetRoleOutput{Role: r}, nil + return &iam.GetRoleOutput{Role: copyRole(r)}, nil } func (e *SystemInstanceRoleEnsurer) CreateRole(accountID string, in *iam.CreateRoleInput) (*iam.CreateRoleOutput, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.CreateRoleCalls++ e.LastTrustDoc = aws.StringValue(in.AssumeRolePolicyDocument) e.LastRoleAcct = accountID @@ -85,10 +111,13 @@ func (e *SystemInstanceRoleEnsurer) CreateRole(accountID string, in *iam.CreateR Arn: aws.String("arn:aws:iam::" + accountID + ":role/" + name), } e.Roles[name] = r - return &iam.CreateRoleOutput{Role: r}, nil + return &iam.CreateRoleOutput{Role: copyRole(r)}, nil } func (e *SystemInstanceRoleEnsurer) PutRolePolicy(_ string, in *iam.PutRolePolicyInput) (*iam.PutRolePolicyOutput, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.PolicyCalls = append(e.PolicyCalls, *in) e.RolePolicies[aws.StringValue(in.RoleName)] = aws.StringValue(in.PolicyDocument) if e.PutRolePolicyErr != nil { @@ -98,14 +127,20 @@ func (e *SystemInstanceRoleEnsurer) PutRolePolicy(_ string, in *iam.PutRolePolic } func (e *SystemInstanceRoleEnsurer) GetInstanceProfile(_ string, in *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) { + e.mu.Lock() + defer e.mu.Unlock() + p, ok := e.Profiles[aws.StringValue(in.InstanceProfileName)] if !ok { return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) } - return &iam.GetInstanceProfileOutput{InstanceProfile: p}, nil + return &iam.GetInstanceProfileOutput{InstanceProfile: copyProfile(p)}, nil } func (e *SystemInstanceRoleEnsurer) CreateInstanceProfile(accountID string, in *iam.CreateInstanceProfileInput) (*iam.CreateInstanceProfileOutput, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.CreateInstanceProfileCalls++ e.LastProfileAcct = accountID if e.CreateInstanceProfileErr != nil { @@ -124,10 +159,13 @@ func (e *SystemInstanceRoleEnsurer) CreateInstanceProfile(accountID string, in * Arn: aws.String(arn), } e.Profiles[name] = p - return &iam.CreateInstanceProfileOutput{InstanceProfile: p}, nil + return &iam.CreateInstanceProfileOutput{InstanceProfile: copyProfile(p)}, nil } func (e *SystemInstanceRoleEnsurer) AddRoleToInstanceProfile(_ string, in *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.AddRoleToInstanceProfileCalls++ if e.AddRoleToInstanceProfileErr != nil { return nil, e.AddRoleToInstanceProfileErr diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index 537f12d76..1099f146a 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -112,6 +112,9 @@ var _ IAMService = (*IAMServiceImpl)(nil) // clusterSize sets the replication factor; pass 1 for single-node or test setups. // The context bounds bucket creation and the schema migrations only. func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byte, clusterSize int) (*IAMServiceImpl, error) { + if builtinManagedPolicyParseErr != nil { + return nil, fmt.Errorf("init builtin managed policies: %w", builtinManagedPolicyParseErr) + } if len(masterKey) != 32 { return nil, fmt.Errorf("master key must be 32 bytes, got %d", len(masterKey)) } diff --git a/spinifex/handlers/rds/agent.go b/spinifex/handlers/rds/agent.go index 9ba4962b0..17f42ff6d 100644 --- a/spinifex/handlers/rds/agent.go +++ b/spinifex/handlers/rds/agent.go @@ -78,6 +78,13 @@ type GetDBBootstrapConfigOutput struct { MasterUserPassword *string `json:"masterUserPassword,omitempty" locationName:"MasterUserPassword"` Port int64 `json:"port" locationName:"Port"` + // The customer ENI address the engine binds, and the range its client + // authentication rules admit. Together they make the endpoint the only path + // to the engine: the management NICs no customer security group governs are + // not bound at all. + ListenAddress string `json:"listenAddress" locationName:"ListenAddress"` + ClientCIDR string `json:"clientCidr" locationName:"ClientCIDR"` + DataVolumeID string `json:"dataVolumeId,omitempty" locationName:"DataVolumeId" xml:"DataVolumeId"` DataVolumeSerial string `json:"dataVolumeSerial,omitempty" locationName:"DataVolumeSerial" xml:"DataVolumeSerial"` VMGeneration int64 `json:"vmGeneration,omitempty" locationName:"VMGeneration" xml:"VMGeneration"` @@ -348,6 +355,18 @@ func (s *Service) GetDBBootstrapConfig(ctx context.Context, input *GetDBBootstra return nil, errors.New(awserrors.ErrorAccessDenied) } + // Both fail the fetch rather than falling back to a wildcard. The agent + // retries on a backoff, so an unresolvable one presents as a stuck bootstrap + // with the reason on the guest console rather than as an engine that came up + // reachable from every network it is attached to. + if rec.ENIPrivateIP == "" { + return nil, errors.New("rds bootstrap: the DB instance record carries no endpoint ENI address for the engine to bind") + } + clientCIDR, err := s.resolveClientCIDR(ctx, kv, &rec) + if err != nil { + return nil, err + } + out := &GetDBBootstrapConfigOutput{ Mode: BootstrapModeAttach, DBInstanceIdentifier: rec.DBInstanceIdentifier, @@ -356,6 +375,8 @@ func (s *Service) GetDBBootstrapConfig(ctx context.Context, input *GetDBBootstra DBName: rec.DBName, MasterUsername: rec.MasterUsername, Port: rec.Port, + ListenAddress: rec.ENIPrivateIP, + ClientCIDR: clientCIDR, DataVolumeID: rec.DataVolumeID, DataVolumeSerial: serial, VMGeneration: rec.VMGeneration, @@ -380,6 +401,37 @@ func (s *Service) GetDBBootstrapConfig(ctx context.Context, input *GetDBBootstra return out, nil } +// The range the guest's client authentication rules admit. A record predating +// the field is backfilled from its VPC and persisted, so the describe is paid +// once rather than on every boot of every instance. +// +// A failure to persist the backfill is not a failure to serve it: the value is +// re-derivable and the next fetch records it, whereas failing here would hold a +// bootable instance down for a bookkeeping write. +func (s *Service) resolveClientCIDR(ctx context.Context, kv jetstream.KeyValue, rec *DBInstanceRecord) (string, error) { + if rec.VpcCIDR != "" { + return rec.VpcCIDR, nil + } + if rec.VpcID == "" { + return "", fmt.Errorf("rds bootstrap: DB instance %s records no VPC to scope its client authentication to", + rec.DBInstanceIdentifier) + } + if s.deps.Network == nil { + return "", errors.New("rds bootstrap: RDS networking is not wired on this node") + } + cidr, err := s.vpcCIDR(ctx, rec.AccountID, rec.VpcID) + if err != nil { + return "", err + } + if err := s.updateInstance(ctx, kv, rec.DBInstanceIdentifier, func(stored *DBInstanceRecord) { + stored.VpcCIDR = cidr + }); err != nil { + slog.ErrorContext(ctx, "rds bootstrap: the VPC address range could not be recorded; serving it regardless", + "dbInstance", rec.DBInstanceIdentifier, "vpcId", rec.VpcID, "err", err) + } + return cidr, nil +} + // Fills in the initialize half of the response when a payload is staged for this // generation, and leaves the attach response untouched otherwise. // @@ -556,6 +608,7 @@ func (s *Service) mintServingCert(rec *DBInstanceRecord) (*bootstrapCert, error) DBInstanceIdentifier: rec.DBInstanceIdentifier, PrivateIP: rec.ENIPrivateIP, DNSName: rec.DNSName, + KeyBits: s.deps.ServingCertKeyBits, }) if err != nil { return nil, err diff --git a/spinifex/handlers/rds/agent_test.go b/spinifex/handlers/rds/agent_test.go index c722b308c..1bfe14bc8 100644 --- a/spinifex/handlers/rds/agent_test.go +++ b/spinifex/handlers/rds/agent_test.go @@ -22,9 +22,16 @@ import ( "github.com/stretchr/testify/require" ) +// Every test service mints its serving certs at 1024 bits. The path under test +// is the same one production runs; only the keygen, which dominated it under +// GOFIPS140, gets cheaper. +const testServingCertKeyBits = 1024 + const ( testDBID = "orders-db" testInstance = "i-0abc123" + testVpcID = "vpc-0aaa111" + testVpcCIDR = "10.20.0.0/16" ) // Minted once per package rather than per harness. An RSA-2048 keygen costs @@ -66,7 +73,9 @@ func newTestCA(t *testing.T) CALoader { func newTestService(t *testing.T) *Service { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) - return NewService(nc, testRegion).WithDeps(Deps{LoadCA: newTestCA(t), MasterKey: testMasterKey}) + return NewService(nc, testRegion).WithDeps(Deps{ + LoadCA: newTestCA(t), MasterKey: testMasterKey, ServingCertKeyBits: testServingCertKeyBits, + }) } // seedInstance writes a DB instance record without the payload a pending @@ -93,6 +102,8 @@ func defaultRecord() DBInstanceRecord { InstanceID: testInstance, VMGeneration: firstVMGeneration, ENIPrivateIP: "10.20.30.40", + VpcID: testVpcID, + VpcCIDR: testVpcCIDR, DNSName: "orders-db.123456789012.ap-southeast-2.rds.example.internal", Bootstrap: BootstrapState{ State: BootstrapStatePending, @@ -271,7 +282,11 @@ func TestGetDBBootstrapConfig_NoCAStillServesConfig(t *testing.T) { assert.Equal(t, int64(5432), out.Port) } -func TestGetDBBootstrapConfig_ConfiguredCARequiresENIPrivateIP(t *testing.T) { +// The address the engine binds, so a record without one fails the fetch on +// every deployment rather than only where TLS needs it as a SAN. The agent +// retries, so this is a stuck bootstrap with a reason rather than an engine +// that came up on the wildcard. +func TestGetDBBootstrapConfig_RequiresENIPrivateIP(t *testing.T) { t.Parallel() svc := newTestService(t) rec := defaultRecord() @@ -280,7 +295,69 @@ func TestGetDBBootstrapConfig_ConfiguredCARequiresENIPrivateIP(t *testing.T) { _, err := svc.GetDBBootstrapConfig(t.Context(), bootstrapInput(), testAccountID) require.Error(t, err) - assert.Contains(t, err.Error(), "configured TLS requires an ENI private IP") + assert.Contains(t, err.Error(), "no endpoint ENI address") + + envelope, _ := storedPayload(t, svc, testDBID) + assert.NotNil(t, envelope, "a failed fetch must leave the payload replayable") +} + +// The endpoint address and the VPC range reach the guest, which is what makes +// the engine bind one NIC and admit one range. +func TestGetDBBootstrapConfig_CarriesListenAddressAndClientCIDR(t *testing.T) { + t.Parallel() + svc := newTestService(t) + rec := defaultRecord() + seedInstance(t, svc, rec) + + out, err := svc.GetDBBootstrapConfig(t.Context(), + &GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID) + require.NoError(t, err) + assert.Equal(t, rec.ENIPrivateIP, out.ListenAddress) + assert.Equal(t, testVpcCIDR, out.ClientCIDR) +} + +// A record predating the field resolves its VPC once and keeps the answer, so +// the describe is not paid on every boot of every instance. +func TestGetDBBootstrapConfig_BackfillsVpcCIDR(t *testing.T) { + t.Parallel() + _, nc, _ := testutil.StartTestJetStream(t) + network := newFakeNetwork() + svc := NewService(nc, testRegion).WithDeps(Deps{ + LoadCA: newTestCA(t), MasterKey: testMasterKey, Network: network, + ServingCertKeyBits: testServingCertKeyBits, + }) + rec := defaultRecord() + rec.VpcID = testDefaultVPC + rec.VpcCIDR = "" + seedInstance(t, svc, rec) + + in := &GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance} + out, err := svc.GetDBBootstrapConfig(t.Context(), in, testAccountID) + require.NoError(t, err) + assert.Equal(t, testDefaultVPCCIDR, out.ClientCIDR) + + stored, _ := readRecord(t, svc) + assert.Equal(t, testDefaultVPCCIDR, stored.VpcCIDR, "the backfill must be persisted") + + describes := len(network.accts) + _, err = svc.GetDBBootstrapConfig(t.Context(), in, testAccountID) + require.NoError(t, err) + assert.Len(t, network.accts, describes, "a backfilled record must not describe its VPC again") +} + +// Without a VPC on the record there is nothing to scope client authentication +// to, and the permissive reading is the one this plan exists to remove. +func TestGetDBBootstrapConfig_UnresolvableClientCIDRFailsClosed(t *testing.T) { + t.Parallel() + svc := newTestService(t) + rec := defaultRecord() + rec.VpcID = "" + rec.VpcCIDR = "" + seedPending(t, svc, rec) + + _, err := svc.GetDBBootstrapConfig(t.Context(), bootstrapInput(), testAccountID) + require.Error(t, err) + assert.Contains(t, err.Error(), "records no VPC") envelope, _ := storedPayload(t, svc, testDBID) assert.NotNil(t, envelope, "a failed fetch must leave the payload replayable") @@ -312,7 +389,8 @@ func TestGetDBBootstrapConfig_UnloadableCALeavesPasswordRecoverable(t *testing.T loadErr := errors.New("read CA key /etc/spinifex/ca.key: permission denied") broken := true svc := NewService(nc, testRegion).WithDeps(Deps{ - MasterKey: testMasterKey, + MasterKey: testMasterKey, + ServingCertKeyBits: testServingCertKeyBits, LoadCA: func() (*x509.Certificate, *rsa.PrivateKey, error) { if broken { return nil, nil, loadErr diff --git a/spinifex/handlers/rds/backup.go b/spinifex/handlers/rds/backup.go index 9379678cc..179ada89a 100644 --- a/spinifex/handlers/rds/backup.go +++ b/spinifex/handlers/rds/backup.go @@ -31,12 +31,6 @@ const ( // would pay the whole storage cost above for a fraction of the coverage. defaultBackupRetentionDays = 7 - // The UTC blocks an unnamed window is assigned inside. Deliberately disjoint: - // the two derived windows then cannot overlap, which is the pair AWS refuses - // to accept and the pair that would quiesce an engine mid-maintenance. - defaultBackupWindowBlock = "03:00-11:00" //nolint:gosec // a UTC window block, not a credential - defaultMaintenanceWindowBlock = "11:00-19:00" - // How many automated snapshots one retention pass may delete. A pass that // under-collects is corrected by the next one two minutes later; a pass that // walks an unbounded number of snapshots is not. @@ -60,6 +54,13 @@ const ( automatedBackupRetryShiftCap = 3 ) +// The UTC blocks an unnamed window is assigned inside. Deliberately disjoint, +// so derived backup and maintenance windows cannot overlap. +var ( + defaultBackupWindowBlock = dailyWindow{start: 3 * time.Hour, end: 11 * time.Hour} + defaultMaintenanceWindowBlock = dailyWindow{start: 11 * time.Hour, end: 19 * time.Hour} +) + // The operator-tunable backup settings: bounds and defaults, never a per-instance // setting — those live on the DB instance record. type BackupPolicy struct { @@ -114,20 +115,15 @@ func (s *Service) maintenanceWindowBlock() dailyWindow { return s.windowBlock(s.deps.Backup.MaintenanceWindowBlock, defaultMaintenanceWindowBlock, "maintenance") } -func (s *Service) windowBlock(configured, fallback, kind string) dailyWindow { +func (s *Service) windowBlock(configured string, fallback dailyWindow, kind string) dailyWindow { if configured != "" { if block, err := parseDailyWindow("block", configured); err == nil { return block } slog.Warn("rds: the configured "+kind+" window block is malformed; using the built-in block", - "block", configured, "fallback", fallback) + "block", configured, "fallback", fallback.String()) } - block, err := parseDailyWindow("block", fallback) - if err != nil { - // Unreachable: the fallbacks are constants this package's tests parse. - panic("rds: built-in " + kind + " window block is malformed: " + err.Error()) - } - return block + return fallback } // The window in force for this instance. A record that names one uses it; one diff --git a/spinifex/handlers/rds/backup_sweep_test.go b/spinifex/handlers/rds/backup_sweep_test.go index fcb3b049f..1c65c0547 100644 --- a/spinifex/handlers/rds/backup_sweep_test.go +++ b/spinifex/handlers/rds/backup_sweep_test.go @@ -246,12 +246,13 @@ func TestSweep_IsBoundedPerPassAndResumes(t *testing.T) { t.Parallel() h := newSnapshotHarness(t, false) h.svc = h.svc.WithDeps(Deps{ - LoadCA: newTestCA(t), - MasterKey: testMasterKey, - Launch: h.launch.deps(), - Network: h.network, - Snapshots: h.snaps, - Backup: BackupPolicy{SweepDeleteLimit: 1}, + LoadCA: newTestCA(t), + MasterKey: testMasterKey, + Launch: h.launch.deps(), + Network: h.network, + Snapshots: h.snaps, + Backup: BackupPolicy{SweepDeleteLimit: 1}, + ServingCertKeyBits: testServingCertKeyBits, }) seedInstance(t, h.svc, retainingRecord(0)) for _, age := range []time.Duration{oneDay, 2 * oneDay, 3 * oneDay} { diff --git a/spinifex/handlers/rds/backup_test.go b/spinifex/handlers/rds/backup_test.go index acb7477b1..f3520242a 100644 --- a/spinifex/handlers/rds/backup_test.go +++ b/spinifex/handlers/rds/backup_test.go @@ -321,13 +321,8 @@ func TestWindowBlock_FallsBackToTheBuiltInBlock(t *testing.T) { MaintenanceWindowBlock: "19:00", }}) - builtIn, err := parseDailyWindow("block", defaultBackupWindowBlock) - require.NoError(t, err) - assert.Equal(t, builtIn, svc.backupWindowBlock()) - - builtIn, err = parseDailyWindow("block", defaultMaintenanceWindowBlock) - require.NoError(t, err) - assert.Equal(t, builtIn, svc.maintenanceWindowBlock()) + assert.Equal(t, defaultBackupWindowBlock, svc.backupWindowBlock()) + assert.Equal(t, defaultMaintenanceWindowBlock, svc.maintenanceWindowBlock()) } func TestValidateRetentionPeriod_BoundsTheRetention(t *testing.T) { diff --git a/spinifex/handlers/rds/bootstrap_test.go b/spinifex/handlers/rds/bootstrap_test.go index f18d4fee2..f3101d47a 100644 --- a/spinifex/handlers/rds/bootstrap_test.go +++ b/spinifex/handlers/rds/bootstrap_test.go @@ -304,7 +304,10 @@ func TestAcknowledgeDBBootstrap_NeverDecrypts(t *testing.T) { svc := newTestService(t) payloadID := seedPending(t, svc, defaultRecord()) - rotated := svc.WithDeps(Deps{LoadCA: newTestCA(t), MasterKey: []byte(testOtherMasterKeyS)}) + rotated := svc.WithDeps(Deps{ + LoadCA: newTestCA(t), MasterKey: []byte(testOtherMasterKeyS), + ServingCertKeyBits: testServingCertKeyBits, + }) _, err := rotated.AcknowledgeDBBootstrap(t.Context(), &AcknowledgeDBBootstrapInput{ DBInstanceIdentifier: testDBID, InstanceID: testInstance, @@ -388,7 +391,10 @@ func TestGetDBBootstrapConfig_UnreadablePayloadAttachesAndReportsWhy(t *testing. svc := newTestService(t) seedPending(t, svc, defaultRecord()) - rotated := svc.WithDeps(Deps{LoadCA: newTestCA(t), MasterKey: []byte(testOtherMasterKeyS)}) + rotated := svc.WithDeps(Deps{ + LoadCA: newTestCA(t), MasterKey: []byte(testOtherMasterKeyS), + ServingCertKeyBits: testServingCertKeyBits, + }) out, err := rotated.GetDBBootstrapConfig(t.Context(), bootstrapInput(), testAccountID) require.NoError(t, err) assert.Equal(t, BootstrapModeAttach, out.Mode) @@ -433,10 +439,11 @@ func TestCreateDBInstance_WithoutAMasterKeyStagesNothing(t *testing.T) { t.Parallel() h := newCreateHarness(t, "") h.svc = h.svc.WithDeps(Deps{ - LoadCA: newTestCA(t), - Launch: h.launch.deps(), - Network: h.network, - IAM: testIAMProvider(h.iam), + LoadCA: newTestCA(t), + Launch: h.launch.deps(), + Network: h.network, + IAM: testIAMProvider(h.iam), + ServingCertKeyBits: testServingCertKeyBits, }) _, err := h.svc.CreateDBInstance(t.Context(), validCreateInput(), testAccountID) diff --git a/spinifex/handlers/rds/create.go b/spinifex/handlers/rds/create.go index d19e09947..88fc20e9c 100644 --- a/spinifex/handlers/rds/create.go +++ b/spinifex/handlers/rds/create.go @@ -166,6 +166,7 @@ func newDBInstanceRecord(accountID string, req *validatedCreate, placement *endp Port: req.Port, SubnetID: placement.SubnetID, VpcID: placement.VpcID, + VpcCIDR: placement.VpcCIDR, VpcSecurityGroupIDs: placement.SecurityGroupIDs, DBSubnetGroupName: req.DBSubnetGroupName, DBParameterGroupName: req.DBParameterGroupName, @@ -214,6 +215,7 @@ func (s *Service) recordLaunch(ctx context.Context, kv jetstream.KeyValue, key, rec.InstanceID = launched.InstanceID rec.VMGeneration = firstVMGeneration rec.SystemENIID = launched.SystemENIID + rec.SystemSGID = launched.SystemSGID rec.ENIID = launched.CustomerENIID rec.ENIPrivateIP = launched.CustomerENIIP rec.DataVolumeID = launched.DataVolumeID diff --git a/spinifex/handlers/rds/create_test.go b/spinifex/handlers/rds/create_test.go index c706482b2..ecb1744fe 100644 --- a/spinifex/handlers/rds/create_test.go +++ b/spinifex/handlers/rds/create_test.go @@ -24,10 +24,11 @@ import ( ) const ( - testDefaultVPC = "vpc-default01" - testDefaultSG = "sg-default01" - testBaseDomain = "spx3.net" - testDBInstanceID = "orders-db" + testDefaultVPC = "vpc-default01" + testDefaultVPCCIDR = "10.0.0.0/16" + testDefaultSG = "sg-default01" + testBaseDomain = "spx3.net" + testDBInstanceID = "orders-db" // The one zone this platform exposes, which every subnet reports. testZone = "spinifexz1" ) @@ -55,7 +56,11 @@ var _ networkResolver = (*fakeNetwork)(nil) func newFakeNetwork() *fakeNetwork { return &fakeNetwork{ - vpcs: []*ec2.Vpc{{VpcId: aws.String(testDefaultVPC), IsDefault: aws.Bool(true)}}, + vpcs: []*ec2.Vpc{{ + VpcId: aws.String(testDefaultVPC), + IsDefault: aws.Bool(true), + CidrBlock: aws.String(testDefaultVPCCIDR), + }}, // Deliberately out of order: the resolver sorts, so the placement is the // same on every repeat regardless of how the describe happened to order. subnets: []*ec2.Subnet{ @@ -85,7 +90,17 @@ func (f *fakeNetwork) DescribeVpcs(_ context.Context, input *ec2.DescribeVpcsInp if f.vpcErr != nil { return nil, f.vpcErr } - return &ec2.DescribeVpcsOutput{Vpcs: f.vpcs}, nil + if input == nil || len(input.VpcIds) == 0 { + return &ec2.DescribeVpcsOutput{Vpcs: f.vpcs}, nil + } + wanted := aws.StringValueSlice(input.VpcIds) + var matched []*ec2.Vpc + for _, vpc := range f.vpcs { + if slices.Contains(wanted, aws.StringValue(vpc.VpcId)) { + matched = append(matched, vpc) + } + } + return &ec2.DescribeVpcsOutput{Vpcs: matched}, nil } // Honours SubnetIds the way EC2 does — a named subnet that is not in this @@ -150,12 +165,13 @@ func newCreateHarness(t *testing.T, baseDomain string) *createHarness { h.stubDNSWriter(t) h.svc = NewService(nc, testRegion).WithDeps(Deps{ - LoadCA: newTestCA(t), - MasterKey: testMasterKey, - Launch: h.launch.deps(), - Network: h.network, - IAM: testIAMProvider(h.iam), - BaseDomain: baseDomain, + LoadCA: newTestCA(t), + MasterKey: testMasterKey, + Launch: h.launch.deps(), + Network: h.network, + IAM: testIAMProvider(h.iam), + BaseDomain: baseDomain, + ServingCertKeyBits: testServingCertKeyBits, }) return h } diff --git a/spinifex/handlers/rds/engine.go b/spinifex/handlers/rds/engine.go index fbba29567..a3f93a711 100644 --- a/spinifex/handlers/rds/engine.go +++ b/spinifex/handlers/rds/engine.go @@ -63,40 +63,45 @@ var engines = map[string]Engine{ // The same registry keyed by parameter-group family, for the callers that hold // only a family string and have no instance to derive an engine from. Family // and engine are 1:1 by construction, so one registry serves both. -var enginesByFamily = indexEnginesByFamily() +var enginesByFamily, engineRegistryValidationErr = indexEnginesByFamily(engines) -func indexEnginesByFamily() map[string]Engine { - out := make(map[string]Engine, len(engines)) - for _, engine := range engines { - // An engine registered without its own name rule would panic on the first - // create that names a database, rather than here where every build sees it. +// ValidateEngineRegistry reports invalid built-in engine metadata before RDS starts. +func ValidateEngineRegistry() error { + if err := validateParameterCatalogs(); err != nil { + return err + } + return engineRegistryValidationErr +} + +func indexEnginesByFamily(registry map[string]Engine) (map[string]Engine, error) { + out := make(map[string]Engine, len(registry)) + for _, engine := range registry { if engine.validateDBName == nil { - panic("rds: engine " + engine.Name + " registers no DBName rule") + return nil, errors.New("rds: engine " + engine.Name + " registers no DBName rule") } - // An engine without these would tell a customer nothing about what a - // crash-consistent snapshot or an unclean stop of it recovers, on the two - // events that say so. + // Without these, snapshot and unclean-stop events cannot explain what the + // engine will recover. if engine.crashRecoveryNote == "" { - panic("rds: engine " + engine.Name + " registers no crash-recovery note") + return nil, errors.New("rds: engine " + engine.Name + " registers no crash-recovery note") } if engine.uncleanStopNote == "" { - panic("rds: engine " + engine.Name + " registers no unclean-stop note") + return nil, errors.New("rds: engine " + engine.Name + " registers no unclean-stop note") } // A name no catalog entry answers to would leave the guest deriving // enforcement from a key nothing ever writes, which reads as not enforcing. if name := engine.tlsEnforcementParameter; name != "" { spec, ok := engine.catalog[name] if !ok || spec.DataType != ParamTypeBoolean { - panic("rds: engine " + engine.Name + " names " + name + ", which is not a boolean parameter it exposes") + return nil, errors.New("rds: engine " + engine.Name + " names " + name + ", which is not a boolean parameter it exposes") } } family := engine.ParameterGroupFamily() if _, exists := out[family]; exists { - panic("rds: parameter group family " + family + " is claimed by two engines") + return nil, errors.New("rds: parameter group family " + family + " is claimed by two engines") } out[family] = engine } - return out + return out, nil } // The pinned version an AMI lookup resolves against, which is the major alone: diff --git a/spinifex/handlers/rds/engine_mariadb.go b/spinifex/handlers/rds/engine_mariadb.go index e795ba8c7..f72d0e3e0 100644 --- a/spinifex/handlers/rds/engine_mariadb.go +++ b/spinifex/handlers/rds/engine_mariadb.go @@ -49,7 +49,7 @@ var engineMariaDB = Engine{ // Platform-owned settings are absent, except the ones AWS exposes as modifiable // and this platform pins: those are present and unmodifiable, so a refusal reads // as policy rather than as a missing feature. -var mariadbParameterCatalog = buildParameterCatalog( +var mariadbParameterCatalog, mariadbParameterCatalogErr = buildParameterCatalog( // Connections and threads. max_connections is what a size-derived default // matters most for: RDS's own formula is {DBInstanceClassMemory/12582880}. ParameterSpec{ diff --git a/spinifex/handlers/rds/engine_postgres.go b/spinifex/handlers/rds/engine_postgres.go index f9aa8442f..34914a268 100644 --- a/spinifex/handlers/rds/engine_postgres.go +++ b/spinifex/handlers/rds/engine_postgres.go @@ -43,7 +43,7 @@ var enginePostgres = Engine{ // Platform-owned settings are absent, except the ones AWS exposes as modifiable // and this platform pins: those are present and unmodifiable, so a refusal reads // as policy rather than as a missing feature. -var postgresParameterCatalog = buildParameterCatalog( +var postgresParameterCatalog, postgresParameterCatalogErr = buildParameterCatalog( // Connections. max_connections is what a size-derived default matters most // for: RDS's own formula is LEAST({DBInstanceClassMemory/9531392}, 5000). ParameterSpec{ diff --git a/spinifex/handlers/rds/enginefamily_test.go b/spinifex/handlers/rds/enginefamily_test.go index db11afe32..3574ee801 100644 --- a/spinifex/handlers/rds/enginefamily_test.go +++ b/spinifex/handlers/rds/enginefamily_test.go @@ -34,18 +34,18 @@ var engineUnderTest = Engine{ reservedUsernamePrefixes: []string{"testdb_"}, maxUsernameLen: 80, validateDBName: dbNameRule(64), - catalog: buildParameterCatalog( - ParameterSpec{ + catalog: map[string]ParameterSpec{ + "buffer_size": { Name: "buffer_size", DataType: ParamTypeInteger, ApplyType: ApplyTypeStatic, IsModifiable: true, Min: 1, Max: 1024, Default: "64", Unit: "MB", Description: "Buffer the server allocates at startup, in MB.", }, - ParameterSpec{ + "connection_limit": { Name: "connection_limit", DataType: ParamTypeInteger, ApplyType: ApplyTypeDynamic, IsModifiable: true, Min: 1, Max: 1000, Default: "100", Description: "Maximum concurrent connections to the server.", }, - ), + }, validateCombinations: func([]Parameter) error { return nil }, crashRecoveryNote: "It will recover when it is restored.", uncleanStopNote: "It will recover on the next start.", @@ -57,13 +57,75 @@ func registerTestEngine(t *testing.T) { t.Helper() require.NotContains(t, engines, engineUnderTest.Name) engines[engineUnderTest.Name] = engineUnderTest - enginesByFamily = indexEnginesByFamily() + indexed, err := indexEnginesByFamily(engines) + require.NoError(t, err) + enginesByFamily = indexed t.Cleanup(func() { delete(engines, engineUnderTest.Name) - enginesByFamily = indexEnginesByFamily() + indexed, err := indexEnginesByFamily(engines) + require.NoError(t, err) + enginesByFamily = indexed }) } +func TestValidateEngineRegistry(t *testing.T) { + require.NoError(t, ValidateEngineRegistry()) +} + +func TestIndexEnginesByFamily_RejectsInvalidMetadata(t *testing.T) { + noDBNameRule := engineUnderTest + noDBNameRule.validateDBName = nil + noCrashRecoveryNote := engineUnderTest + noCrashRecoveryNote.crashRecoveryNote = "" + noUncleanStopNote := engineUnderTest + noUncleanStopNote.uncleanStopNote = "" + invalidTLSParameter := engineUnderTest + invalidTLSParameter.tlsEnforcementParameter = "not-a-boolean-parameter" + + tests := []struct { + name string + registry map[string]Engine + wantErr string + }{ + { + name: "missing DB name rule", + registry: map[string]Engine{"test": noDBNameRule}, + wantErr: "registers no DBName rule", + }, + { + name: "missing crash recovery note", + registry: map[string]Engine{"test": noCrashRecoveryNote}, + wantErr: "registers no crash-recovery note", + }, + { + name: "missing unclean stop note", + registry: map[string]Engine{"test": noUncleanStopNote}, + wantErr: "registers no unclean-stop note", + }, + { + name: "invalid TLS enforcement parameter", + registry: map[string]Engine{"test": invalidTLSParameter}, + wantErr: "is not a boolean parameter it exposes", + }, + { + name: "duplicate parameter group family", + registry: map[string]Engine{ + "first": engineUnderTest, + "second": engineUnderTest, + }, + wantErr: "is claimed by two engines", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + indexed, err := indexEnginesByFamily(tt.registry) + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, indexed) + }) + } +} + func testEngineGroupInput(name string) *rds.CreateDBParameterGroupInput { return &rds.CreateDBParameterGroupInput{ DBParameterGroupName: aws.String(name), diff --git a/spinifex/handlers/rds/iam.go b/spinifex/handlers/rds/iam.go index 8f1d4b173..239cdc85e 100644 --- a/spinifex/handlers/rds/iam.go +++ b/spinifex/handlers/rds/iam.go @@ -31,7 +31,7 @@ var InternalAgentActions = []string{ // Never rds:*: granting the customer surface here would let a Postgres RCE on // one DB VM manage its account's whole fleet. -var instanceRoleInlinePolicy = func() string { +func instanceRoleInlinePolicy() (string, error) { actions := make(handlers_iam.StringOrArr, 0, len(InternalAgentActions)) for _, action := range InternalAgentActions { actions = append(actions, "rds:"+action) @@ -45,10 +45,10 @@ var instanceRoleInlinePolicy = func() string { }}, }) if err != nil { - panic("rds: cannot marshal the instance-role policy: " + err.Error()) + return "", fmt.Errorf("marshal instance-role policy: %w", err) } - return string(doc) -}() + return string(doc), nil +} // Resolved per launch rather than held, mirroring EKS: the KV-backed IAM // service has no responders until JetStream is up, so an eager build races @@ -65,8 +65,12 @@ func ensureInstanceProfile(provider IAMProvider, accountID string) (string, erro if iamSvc == nil { return "", errors.New("rds: IAM service is unavailable") } + policy, err := instanceRoleInlinePolicy() + if err != nil { + return "", fmt.Errorf("rds: construct instance-role inline policy: %w", err) + } profileARN, err := handlers_iam.EnsureSystemInstanceProfile(iamSvc, accountID, - InstanceRoleName, instanceRoleInlinePolicyName, instanceRoleInlinePolicy) + InstanceRoleName, instanceRoleInlinePolicyName, policy) if err != nil { return "", fmt.Errorf("rds: ensure instance profile %s for account %s: %w", InstanceRoleName, accountID, err) diff --git a/spinifex/handlers/rds/iam_test.go b/spinifex/handlers/rds/iam_test.go index 8af13819f..e2fba7ebd 100644 --- a/spinifex/handlers/rds/iam_test.go +++ b/spinifex/handlers/rds/iam_test.go @@ -28,8 +28,10 @@ func rdsInstanceProfileARN(accountID string) string { // internal actions and nothing wildcarded that could reach the customer surface. func TestInstanceRolePolicy_GrantsOnlyTheInternalActions(t *testing.T) { t.Parallel() + policy, err := instanceRoleInlinePolicy() + require.NoError(t, err) var doc handlers_iam.PolicyDocument - require.NoError(t, json.Unmarshal([]byte(instanceRoleInlinePolicy), &doc)) + require.NoError(t, json.Unmarshal([]byte(policy), &doc)) require.Len(t, doc.Statement, 1) stmt := doc.Statement[0] @@ -81,9 +83,11 @@ func TestEnsureInstanceProfile_IsIdempotent(t *testing.T) { assert.Equal(t, 1, f.CreateRoleCalls, "the second launch must find the role, not create it") assert.Equal(t, 1, f.CreateInstanceProfileCalls, "the second launch must find the instance profile") require.Len(t, f.PolicyCalls, 2, "the grant is re-asserted on every launch") + policy, err := instanceRoleInlinePolicy() + require.NoError(t, err) for _, put := range f.PolicyCalls { assert.Equal(t, instanceRoleInlinePolicyName, aws.StringValue(put.PolicyName)) - assert.Equal(t, instanceRoleInlinePolicy, aws.StringValue(put.PolicyDocument)) + assert.Equal(t, policy, aws.StringValue(put.PolicyDocument)) } } diff --git a/spinifex/handlers/rds/launch.go b/spinifex/handlers/rds/launch.go index ac799c014..797ee373e 100644 --- a/spinifex/handlers/rds/launch.go +++ b/spinifex/handlers/rds/launch.go @@ -52,6 +52,24 @@ const ( // validation failure rather than launching something else. var ErrEngineAMINotFound = errors.New("rds: no AMI found for engine") +// The catalog registers every engine's system image under this prefix, so the +// error can name the image an operator has to import. +const engineImageNamePrefix = "spinifex-rds-" + +// An engine image no deployment ever imported is a gap no retry closes, so the +// failure carries a code: a bare error resolves to ServerInternal, which the +// caller reads as a transient fault and retries. +func engineAMINotFound(engine, version string) error { + requested := "an engine image for " + engine + if version != "" { + requested = "version " + version + " for " + engine + } + return fmt.Errorf("%w: %w", ErrEngineAMINotFound, awserrors.Errorf( + awserrors.ErrorInvalidParameterCombination, + "Cannot find %s; this deployment has no %s%s system image installed", + requested, engineImageNamePrefix, engine)) +} + type launchVPCProvisioner interface { CreateNetworkInterface(ctx context.Context, input *ec2.CreateNetworkInterfaceInput, accountID string) (*ec2.CreateNetworkInterfaceOutput, error) DeleteNetworkInterface(ctx context.Context, input *ec2.DeleteNetworkInterfaceInput, accountID string) (*ec2.DeleteNetworkInterfaceOutput, error) @@ -62,6 +80,10 @@ type launchVPCProvisioner interface { // Re-associates the customer ENI's security groups in place, which is what // makes a VpcSecurityGroupIds modify need no VM replace and no new IP. ModifyNetworkInterfaceAttribute(ctx context.Context, input *ec2.ModifyNetworkInterfaceAttributeInput, accountID string) (*ec2.ModifyNetworkInterfaceAttributeOutput, error) + // The system NIC's own group, ensured per launch rather than at daemon + // start, so a deployment whose system VPC was rebuilt still gets one. + CreateSecurityGroup(ctx context.Context, input *ec2.CreateSecurityGroupInput, accountID string) (*ec2.CreateSecurityGroupOutput, error) + DescribeSecurityGroups(ctx context.Context, input *ec2.DescribeSecurityGroupsInput, accountID string) (*ec2.DescribeSecurityGroupsOutput, error) } // System-managed VMs get a mgmt-bridge NIC alongside their VPC NICs, which is @@ -124,7 +146,10 @@ type LaunchOutput struct { InstanceID string // The system ENI is disposable — a replace makes a new one. The customer // ENI is the stable endpoint: its IP is the DNS target and survives. - SystemENIID string + SystemENIID string + // The region's RDS system group, re-derived on every launch so a record + // written before the group existed is corrected by the next replace. + SystemSGID string CustomerENIID string CustomerENIIP string DataVolumeID string @@ -160,6 +185,11 @@ func LaunchDBInstanceVM(ctx context.Context, deps LaunchDeps, in LaunchInput) (o } systemSubnetID := sysRefs.PrivateSubnetIDs[0] + systemSGID, err := EnsureSystemSecurityGroup(ctx, deps.VPC, utils.GlobalAccountID, region, sysRefs.VpcID) + if err != nil { + return nil, err + } + // Unwind in reverse creation order on any failure below. Each step appends // its own undo as soon as the resource exists. var rollback []func(context.Context) @@ -187,7 +217,7 @@ func LaunchDBInstanceVM(ctx context.Context, deps LaunchDeps, in LaunchInput) (o } }() - systemENI, err := createLaunchENI(ctx, deps.VPC, utils.GlobalAccountID, systemSubnetID, nil, + systemENI, err := createLaunchENI(ctx, deps.VPC, utils.GlobalAccountID, systemSubnetID, []string{systemSGID}, "RDS management NIC for "+in.DBInstanceIdentifier, in.DBInstanceIdentifier) if err != nil { return nil, err @@ -227,6 +257,10 @@ func LaunchDBInstanceVM(ctx context.Context, deps LaunchDeps, in LaunchInput) (o ENIIP: customerENI.ip, SubnetID: in.SubnetID, AccountID: in.AccountID, + // The endpoint is the ENI's address, so it has to survive the + // terminate half of a replace the way the datadir volume does. + // A DeleteDBInstance deletes it explicitly once the VM is gone. + DeleteOnTermination: aws.Bool(false), }}, UserData: in.UserData, IamInstanceProfileArn: in.IamInstanceProfileArn, @@ -294,12 +328,14 @@ func LaunchDBInstanceVM(ctx context.Context, deps LaunchDeps, in LaunchInput) (o slog.InfoContext(ctx, "rds: DB VM launched", "dbInstance", in.DBInstanceIdentifier, "instanceId", instanceID, "ami", amiID, - "systemEni", systemENI.id, "customerEni", customerENI.id, "customerEniIp", customerENI.ip, + "systemEni", systemENI.id, "systemSg", systemSGID, + "customerEni", customerENI.id, "customerEniIp", customerENI.ip, "dataVolume", volumeID, "device", device) return &LaunchOutput{ InstanceID: instanceID, SystemENIID: systemENI.id, + SystemSGID: systemSGID, CustomerENIID: customerENI.id, CustomerENIIP: customerENI.ip, DataVolumeID: volumeID, @@ -332,8 +368,9 @@ type launchENI struct { mac string } -// groups is nil for the system NIC, which is unreachable from any customer VPC -// and so needs no security group of its own. +// groups must be non-empty for either NIC: an ENI created with none is placed in +// its VPC's default group, whose sole ingress rule admits every other member of +// itself — which for the shared system VPC is every DB VM in the deployment. func createLaunchENI(ctx context.Context, vpcSvc launchVPCProvisioner, accountID, subnetID string, groups []string, description, dbInstanceID string) (*launchENI, error) { var groupIDs []*string if len(groups) > 0 { @@ -441,7 +478,7 @@ func resolveEngineAMI(ctx context.Context, amiSvc launchAMIResolver, engine, ver return "", fmt.Errorf("rds: describe %s AMI: %w", engine, err) } if out == nil { - return "", fmt.Errorf("%w: %s %s", ErrEngineAMINotFound, engine, version) + return "", engineAMINotFound(engine, version) } // Several builds of one engine version can be registered; skip malformed @@ -466,7 +503,7 @@ func resolveEngineAMI(ctx context.Context, amiSvc launchAMIResolver, engine, ver } } if newestID == "" { - return "", fmt.Errorf("%w: %s %s", ErrEngineAMINotFound, engine, version) + return "", engineAMINotFound(engine, version) } if matches > 1 { slog.WarnContext(ctx, "rds: multiple AMIs match the requested engine; using newest", diff --git a/spinifex/handlers/rds/launch_test.go b/spinifex/handlers/rds/launch_test.go index e68580973..107a3db06 100644 --- a/spinifex/handlers/rds/launch_test.go +++ b/spinifex/handlers/rds/launch_test.go @@ -32,6 +32,10 @@ const ( // target and its MAC is what the replacement VM's NIC has to come up with. testEndpointIP = "10.20.30.40" testEndpointMAC = "02:00:00:00:aa:01" + + // The RDS system group the system NIC carries, in place of the system VPC's + // mutually-permissive default group. + testSystemSG = "sg-rdssystem" ) // --- Fakes --- @@ -193,6 +197,14 @@ type fakeENIs struct { // replace must refuse to mint a replacement address for. describeMissing bool modifyErr error + + // The region's RDS system group, ensured per launch. Recording both halves of + // the lookup-or-create is what makes its idempotence observable. + sgCreated []*ec2.CreateSecurityGroupInput + sgDescribed []*ec2.DescribeSecurityGroupsInput + sgExisting string + sgExistingGroups []*ec2.SecurityGroup + sgCreateErr error } var _ launchVPCProvisioner = (*fakeENIs)(nil) @@ -254,6 +266,30 @@ func (f *fakeENIs) ModifyNetworkInterfaceAttribute(_ context.Context, in *ec2.Mo return &ec2.ModifyNetworkInterfaceAttributeOutput{}, nil } +func (f *fakeENIs) DescribeSecurityGroups(_ context.Context, in *ec2.DescribeSecurityGroupsInput, _ string) (*ec2.DescribeSecurityGroupsOutput, error) { + f.sgDescribed = append(f.sgDescribed, in) + if f.sgExistingGroups != nil { + return &ec2.DescribeSecurityGroupsOutput{SecurityGroups: f.sgExistingGroups}, nil + } + if f.sgExisting == "" { + return &ec2.DescribeSecurityGroupsOutput{}, nil + } + return &ec2.DescribeSecurityGroupsOutput{ + SecurityGroups: []*ec2.SecurityGroup{{GroupId: aws.String(f.sgExisting)}}, + }, nil +} + +// A created group is found by the next describe, as the real API would have it: +// that is what a second launch's idempotence rests on. +func (f *fakeENIs) CreateSecurityGroup(_ context.Context, in *ec2.CreateSecurityGroupInput, _ string) (*ec2.CreateSecurityGroupOutput, error) { + if f.sgCreateErr != nil { + return nil, f.sgCreateErr + } + f.sgCreated = append(f.sgCreated, in) + f.sgExisting = testSystemSG + return &ec2.CreateSecurityGroupOutput{GroupId: aws.String(f.sgExisting)}, nil +} + // fakeLauncher stands in for the system-instance launcher. type fakeLauncher struct { input *sysinstance.SystemInstanceInput @@ -528,7 +564,10 @@ func TestLaunchDBInstanceVMWiresBothNICs(t *testing.T) { assert.Equal(t, utils.GlobalAccountID, h.enis.accts[0]) assert.True(t, strings.HasPrefix(aws.StringValue(sysENI.SubnetId), "subnet-rdssys"), "the primary NIC must land in the RDS system VPC, got %s", aws.StringValue(sysENI.SubnetId)) - assert.Empty(t, sysENI.Groups, "the system NIC is unreachable from any customer VPC and needs no security group") + // Its own ingress-free group, not the system VPC's default one — whose sole + // rule admits every other member of itself, which is every DB VM there is. + assert.Equal(t, []string{testSystemSG}, aws.StringValueSlice(sysENI.Groups)) + assert.Equal(t, testSystemSG, out.SystemSGID) // The customer-facing ENI is created in the customer's account and subnet, // with the customer's security groups: it is the only ingress path. @@ -559,6 +598,9 @@ func TestLaunchDBInstanceVMWiresBothNICs(t *testing.T) { ENIIP: out.CustomerENIIP, SubnetID: testDBSubnet, AccountID: testCustomerAccount, + // The endpoint address lives on this NIC, so it has to survive the + // terminate half of a replace the way the datadir volume does. + DeleteOnTermination: aws.Bool(false), }, in.ExtraENIs[0], "the extra NIC must carry the customer account, or the daemon updates the wrong ENI record") assert.Equal(t, "#cloud-config\n", in.UserData) assert.Equal(t, "arn:aws:iam::000000000000:instance-profile/rdsInstanceRole", in.IamInstanceProfileArn) @@ -566,6 +608,78 @@ func TestLaunchDBInstanceVMWiresBothNICs(t *testing.T) { assert.Equal(t, "i-rds0001", out.InstanceID) } +// The group is per region and shared by every DB VM, so a second launch has to +// find the first one's rather than accumulate a group per instance. +func TestLaunchDBInstanceVMReusesTheSystemSecurityGroup(t *testing.T) { + t.Parallel() + h := newLaunchHarness() + + first, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput()) + require.NoError(t, err) + second, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput()) + require.NoError(t, err) + + assert.Equal(t, first.SystemSGID, second.SystemSGID) + require.Len(t, h.enis.sgCreated, 1, "the region's system group is shared, not minted per DB instance") + + created := h.enis.sgCreated[0] + assert.Equal(t, SystemSecurityGroupName("ap-southeast-2"), aws.StringValue(created.GroupName)) + assert.Equal(t, tags.ManagedByRDS, tagOf(created.TagSpecifications, tags.ManagedByKey)) + + // The name and the VPC together are the lookup key: a group of that name in + // some customer VPC must not be adopted for the system NIC. + require.NotEmpty(t, h.enis.sgDescribed) + filters := map[string]string{} + for _, f := range h.enis.sgDescribed[0].Filters { + filters[aws.StringValue(f.Name)] = aws.StringValue(f.Values[0]) + } + assert.Equal(t, SystemSecurityGroupName("ap-southeast-2"), filters["group-name"]) + assert.NotEmpty(t, filters["vpc-id"]) +} + +// There is no fallback to nil groups, because that fallback is the bug: an ENI +// created with none lands in the VPC's default group. +func TestLaunchDBInstanceVMFailsWhenTheSystemSecurityGroupCannotBeEnsured(t *testing.T) { + t.Parallel() + h := newLaunchHarness() + h.enis.sgCreateErr = errors.New("vpcd unavailable") + + _, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput()) + require.Error(t, err) + assert.Empty(t, h.enis.created, "no NIC may be created before its security group exists") + assert.Nil(t, h.launcher.input) +} + +func TestLaunchDBInstanceVMRejectsAnExistingSystemSecurityGroupWithIngress(t *testing.T) { + t.Parallel() + h := newLaunchHarness() + h.enis.sgExistingGroups = []*ec2.SecurityGroup{{ + GroupId: aws.String(testSystemSG), + IpPermissions: []*ec2.IpPermission{{ + IpProtocol: aws.String("-1"), + }}, + }} + + _, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput()) + require.Error(t, err) + assert.Contains(t, err.Error(), "has ingress rules") + assert.Empty(t, h.enis.created, "an unvalidated group must never reach a system NIC") +} + +func TestLaunchDBInstanceVMRejectsMultipleExistingSystemSecurityGroups(t *testing.T) { + t.Parallel() + h := newLaunchHarness() + h.enis.sgExistingGroups = []*ec2.SecurityGroup{ + {GroupId: aws.String(testSystemSG)}, + {GroupId: aws.String("sg-rdssystem02")}, + } + + _, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput()) + require.Error(t, err) + assert.Contains(t, err.Error(), "found 2 groups") + assert.Empty(t, h.enis.created, "an ambiguous group must never reach a system NIC") +} + func TestLaunchDBInstanceVMAttachesTheDataVolume(t *testing.T) { t.Parallel() h := newLaunchHarness() @@ -814,5 +928,41 @@ func TestLaunchDBInstanceVMFailsWithoutAnEngineAMI(t *testing.T) { _, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput()) require.ErrorIs(t, err, ErrEngineAMINotFound, "a missing engine image must be a named failure, never a fallback to some other AMI") + code, _, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "the launch must not swallow the code on its way out") + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) assert.Empty(t, h.enis.created) } + +func TestResolveEngineAMINotFoundCarriesAClientCode(t *testing.T) { + t.Parallel() + h := newLaunchHarness() + h.images.images = nil + + _, err := resolveEngineAMI(t.Context(), h.images, "postgres", "18") + require.ErrorIs(t, err, ErrEngineAMINotFound) + + code, message, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, + "an uncoded error reaches the caller as a retried ServerInternal, which says nothing about what is missing") + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) + assert.Contains(t, message, "version 18 for postgres") + assert.Contains(t, message, "spinifex-rds-postgres", + "the message names the image an operator has to import") +} + +func TestResolveEngineAMINotFoundWithoutARequestedVersion(t *testing.T) { + t.Parallel() + h := newLaunchHarness() + h.images.images = nil + + _, err := resolveEngineAMI(t.Context(), h.images, "mariadb", "") + require.ErrorIs(t, err, ErrEngineAMINotFound) + + code, message, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) + assert.Contains(t, message, "an engine image for mariadb", + "an unset version must not read back as an empty one") + assert.Contains(t, message, "spinifex-rds-mariadb") +} diff --git a/spinifex/handlers/rds/lifecycle_test.go b/spinifex/handlers/rds/lifecycle_test.go index e360976db..234e55c6d 100644 --- a/spinifex/handlers/rds/lifecycle_test.go +++ b/spinifex/handlers/rds/lifecycle_test.go @@ -15,6 +15,7 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/rds" "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/testutil" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" @@ -231,6 +232,7 @@ type lifecycleHarness struct { cmdr *fakeInstanceCommander snaps *fakeSnapshots enis *fakeENIs + sysvpc *fakeSystemVPC launcher *fakeLauncher volumes *fakeVolumes agent *stubAgent @@ -251,22 +253,26 @@ func newLifecycleHarness(t *testing.T, agentFails bool) *lifecycleHarness { cmdr: &fakeInstanceCommander{vm: vmState}, snaps: &fakeSnapshots{}, enis: &fakeENIs{}, + sysvpc: &fakeSystemVPC{}, launcher: &fakeLauncher{}, volumes: &fakeVolumes{}, vmState: vmState, } h.agent = newStubAgent(t, nc, testAccountID, testDBID, agentFails) h.svc = NewService(nc, testRegion).WithDeps(Deps{ - LoadCA: newTestCA(t), - MasterKey: testMasterKey, - Instances: h.cmdr, - Snapshots: h.snaps, - InstanceState: h.vmState, - VMStopTimeout: testVMStopTimeout, + LoadCA: newTestCA(t), + MasterKey: testMasterKey, + Instances: h.cmdr, + Snapshots: h.snaps, + InstanceState: h.vmState, + VMStopTimeout: testVMStopTimeout, + ServingCertKeyBits: testServingCertKeyBits, Launch: LaunchDeps{ - VPC: h.enis, - Instance: h.launcher, - Volume: h.volumes, + Config: &config.Config{Region: testRegion}, + SystemVPC: h.sysvpc.deps(), + VPC: h.enis, + Instance: h.launcher, + Volume: h.volumes, }, }) return h @@ -278,6 +284,9 @@ func availableRecord() DBInstanceRecord { rec.Status = StatusAvailable rec.ENIID = "eni-cust01" rec.SystemENIID = "eni-sys01" + // Already on the region's system group, as a current launch records it, so a + // lifecycle case is not answering the retrofit sweep instead of its own step. + rec.SystemSGID = testSystemSG rec.DataVolumeID = "vol-rdsdata01" rec.CreatedAt = time.Now().UTC().Add(-time.Hour) return rec diff --git a/spinifex/handlers/rds/modify_test.go b/spinifex/handlers/rds/modify_test.go index 5d4f7ff53..558f8b8d3 100644 --- a/spinifex/handlers/rds/modify_test.go +++ b/spinifex/handlers/rds/modify_test.go @@ -60,15 +60,16 @@ func newModifyHarnessWithAgent(t *testing.T, agentFails bool) *modifyHarness { } h.agent = newStubAgent(t, nc, testAccountID, testDBID, agentFails) h.svc = NewService(nc, testRegion).WithDeps(Deps{ - LoadCA: newTestCA(t), - MasterKey: testMasterKey, - Launch: h.launch.deps(), - Network: h.network, - IAM: testIAMProvider(h.iam), - Instances: h.cmdr, - Storage: h.storage, - InstanceState: h.vmState, - VMStopTimeout: testVMStopTimeout, + LoadCA: newTestCA(t), + MasterKey: testMasterKey, + Launch: h.launch.deps(), + Network: h.network, + IAM: testIAMProvider(h.iam), + Instances: h.cmdr, + Storage: h.storage, + InstanceState: h.vmState, + VMStopTimeout: testVMStopTimeout, + ServingCertKeyBits: testServingCertKeyBits, }) h.rec = NewReconciler(h.svc, "node-a") return h diff --git a/spinifex/handlers/rds/network.go b/spinifex/handlers/rds/network.go index c02ab8b13..f825531b7 100644 --- a/spinifex/handlers/rds/network.go +++ b/spinifex/handlers/rds/network.go @@ -21,7 +21,10 @@ type networkResolver interface { // Where the customer-facing ENI lands. type endpointPlacement struct { - VpcID string + VpcID string + // The VPC's own address range, which becomes the scope of the guest's client + // authentication rules. + VpcCIDR string SubnetID string SecurityGroupIDs []string } @@ -60,7 +63,35 @@ func (s *Service) resolvePlacement(ctx context.Context, kv jetstream.KeyValue, a if err != nil { return nil, err } - return &endpointPlacement{VpcID: vpcID, SubnetID: subnetID, SecurityGroupIDs: groupIDs}, nil + cidr, err := s.vpcCIDR(ctx, accountID, vpcID) + if err != nil { + return nil, err + } + return &endpointPlacement{VpcID: vpcID, VpcCIDR: cidr, SubnetID: subnetID, SecurityGroupIDs: groupIDs}, nil +} + +// The VPC's address range. Resolved rather than derived from the subnet: the +// guest's client authentication rules are scoped to the whole VPC, because an +// app subnet reaching a DB subnet arrives carrying its own address. +func (s *Service) vpcCIDR(ctx context.Context, accountID, vpcID string) (string, error) { + out, err := s.deps.Network.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{ + VpcIds: aws.StringSlice([]string{vpcID}), + }, accountID) + if err != nil { + return "", fmt.Errorf("rds: describe VPC %s: %w", vpcID, err) + } + if out != nil { + for _, vpc := range out.Vpcs { + if aws.StringValue(vpc.VpcId) != vpcID { + continue + } + if cidr := aws.StringValue(vpc.CidrBlock); cidr != "" { + return cidr, nil + } + } + } + return "", awserrors.Errorf(awserrors.ErrorDBInvalidVPCNetworkState, + "VPC %s has no address range to scope the DB instance's client authentication to", vpcID) } // The subnet a group places an instance in. Sorted rather than first-listed, so diff --git a/spinifex/handlers/rds/paramcatalog.go b/spinifex/handlers/rds/paramcatalog.go index 99a706cb3..4467d2ba4 100644 --- a/spinifex/handlers/rds/paramcatalog.go +++ b/spinifex/handlers/rds/paramcatalog.go @@ -1,6 +1,7 @@ package handlers_rds import ( + "errors" "fmt" "maps" "math" @@ -89,27 +90,36 @@ type ParameterSpec struct { optionFileName string } -// Indexes the specs by name and fails the build-equivalent — process start — on -// a malformed entry, so a catalog typo cannot reach a customer's create. -func buildParameterCatalog(specs ...ParameterSpec) map[string]ParameterSpec { +// Indexes the specs by name and returns malformed entries to startup validation. +func buildParameterCatalog(specs ...ParameterSpec) (map[string]ParameterSpec, error) { out := make(map[string]ParameterSpec, len(specs)) for _, spec := range specs { switch { case spec.Name == "": - panic("rds: parameter catalog holds an unnamed entry") + return nil, errors.New("rds: parameter catalog holds an unnamed entry") case spec.Default == "" && spec.DefaultFor == nil: - panic("rds: parameter catalog entry " + spec.Name + " has no default") + return nil, fmt.Errorf("rds: parameter catalog entry %s has no default", spec.Name) case spec.Default != "" && spec.DefaultFor != nil: - panic("rds: parameter catalog entry " + spec.Name + " has both a literal and a computed default") + return nil, fmt.Errorf("rds: parameter catalog entry %s has both a literal and a computed default", spec.Name) case (spec.DefaultFor == nil) != (spec.MaxFor == nil): - panic("rds: parameter catalog entry " + spec.Name + " must pair its computed default and class ceiling") + return nil, fmt.Errorf("rds: parameter catalog entry %s must pair its computed default and class ceiling", spec.Name) } if _, exists := out[spec.Name]; exists { - panic("rds: parameter catalog entry " + spec.Name + " is duplicated") + return nil, fmt.Errorf("rds: parameter catalog entry %s is duplicated", spec.Name) } out[spec.Name] = spec } - return out + return out, nil +} + +func validateParameterCatalogs() error { + if postgresParameterCatalogErr != nil { + return fmt.Errorf("rds: validate PostgreSQL parameter catalog: %w", postgresParameterCatalogErr) + } + if mariadbParameterCatalogErr != nil { + return fmt.Errorf("rds: validate MariaDB parameter catalog: %w", mariadbParameterCatalogErr) + } + return nil } // The catalog entry for a parameter name, or false when the engine has no such diff --git a/spinifex/handlers/rds/paramcatalog_test.go b/spinifex/handlers/rds/paramcatalog_test.go index a1047e211..f3bdcda5e 100644 --- a/spinifex/handlers/rds/paramcatalog_test.go +++ b/spinifex/handlers/rds/paramcatalog_test.go @@ -15,6 +15,66 @@ import ( // customer can reach it. var catalogEngines = []Engine{enginePostgres, engineMariaDB} +func TestBuildParameterCatalog_ReturnsValidationErrors(t *testing.T) { + computedDefault := func(int64) string { return "1" } + classCeiling := func(int64) int64 { return 2 } + tests := []struct { + name string + specs []ParameterSpec + wantErr string + }{ + { + name: "valid entry", + specs: []ParameterSpec{{Name: "valid", Default: "1"}}, + }, + { + name: "unnamed entry", + specs: []ParameterSpec{{Default: "1"}}, + wantErr: "holds an unnamed entry", + }, + { + name: "missing default", + specs: []ParameterSpec{{Name: "missing"}}, + wantErr: "has no default", + }, + { + name: "literal and computed defaults", + specs: []ParameterSpec{{ + Name: "both", Default: "1", DefaultFor: computedDefault, MaxFor: classCeiling, + }}, + wantErr: "has both a literal and a computed default", + }, + { + name: "computed default without ceiling", + specs: []ParameterSpec{{ + Name: "unpaired", DefaultFor: computedDefault, + }}, + wantErr: "must pair its computed default and class ceiling", + }, + { + name: "duplicate entry", + specs: []ParameterSpec{ + {Name: "duplicate", Default: "1"}, + {Name: "duplicate", Default: "2"}, + }, + wantErr: "is duplicated", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + catalog, err := buildParameterCatalog(tt.specs...) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, catalog) + return + } + require.NoError(t, err) + assert.Len(t, catalog, len(tt.specs)) + }) + } +} + // The failure this guards is the one the phase opens by warning about, arriving // through the default path rather than a customer typo: a catalog change that // makes the smallest class unbootable has to fail here rather than in a create. diff --git a/spinifex/handlers/rds/reconciler.go b/spinifex/handlers/rds/reconciler.go index 0187c230c..5c070a344 100644 --- a/spinifex/handlers/rds/reconciler.go +++ b/spinifex/handlers/rds/reconciler.go @@ -84,6 +84,12 @@ type Reconciler struct { // every sweep and crowd the bounded ring. reportedMu sync.Mutex reportedPending map[string]string + + // The region's RDS system security group, resolved once. Ensuring it per + // instance per pass would be a VPC and group describe for every DB VM every + // 15s; the group is regional and nothing deletes it per instance. + systemSGMu sync.Mutex + systemSGID string } // holder identifies this daemon in the lease. @@ -94,19 +100,27 @@ func NewReconciler(svc *Service, holder string) *Reconciler { // Drives the leadership and reconcile loop until ctx is cancelled. Intended as // a daemon-boot goroutine; panics are the caller's recover concern. func (r *Reconciler) Run(ctx context.Context) { - leaseTicker := time.NewTicker(leaseRefresh) + leadershipCtx, cancelLeadership := context.WithCancel(ctx) + leadershipDone := make(chan struct{}) + go func() { + defer close(leadershipDone) + r.maintainLeadership(leadershipCtx, leaseRefresh) + }() + defer func() { + cancelLeadership() + <-leadershipDone + }() + reconcileTicker := time.NewTicker(reconcileInterval) - defer leaseTicker.Stop() defer reconcileTicker.Stop() - r.evaluateLeadership(ctx) for { select { case <-ctx.Done(): + cancelLeadership() + <-leadershipDone r.relinquish() return - case <-leaseTicker.C: - r.evaluateLeadership(ctx) case <-reconcileTicker.C: if !r.isLeader() { continue @@ -118,6 +132,23 @@ func (r *Reconciler) Run(ctx context.Context) { } } +// Leadership refresh runs independently because a fleet pass can contain +// blocking network I/O and must not outlive its lease. +func (r *Reconciler) maintainLeadership(ctx context.Context, refresh time.Duration) { + leaseTicker := time.NewTicker(refresh) + defer leaseTicker.Stop() + + r.evaluateLeadership(ctx) + for { + select { + case <-ctx.Done(): + return + case <-leaseTicker.C: + r.evaluateLeadership(ctx) + } + } +} + // The shared GC backstop's cluster-wide gate. The reconciler's lease is already // cluster-singular and held continuously rather than claimed per sweep, so // holding it is the whole answer and there is nothing for the caller to release. @@ -251,6 +282,10 @@ func (r *Reconciler) reconcileInstance(ctx context.Context, kv jetstream.KeyValu if err := r.reportStalePendingBootstrap(ctx, kv, accountID, &rec); err != nil { return err } + remediated, err := r.remediateSystemENISG(ctx, kv, rev, &rec) + if err != nil || remediated { + return err + } switch rec.Status { case StatusCreating: return r.reconcileCreating(ctx, kv, rev, accountID, &rec) @@ -271,6 +306,67 @@ func (r *Reconciler) reconcileInstance(ctx context.Context, kv jetstream.KeyValu } } +// Moves a system NIC launched before the RDS system security group existed onto +// it, once. Reports whether it acted, so the caller yields this pass and the +// status handler runs on the next tick against a fresh revision. +// +// vpcd applies the requested group list declaratively, so this removes the ENI +// from the system VPC's default group rather than merely adding the new one +// alongside it — and that default group is the whole of the exposure. +func (r *Reconciler) remediateSystemENISG(ctx context.Context, kv jetstream.KeyValue, + rev uint64, rec *DBInstanceRecord) (bool, error) { + if rec.SystemENIID == "" || rec.Status == StatusDeleting { + return false, nil + } + sgID, err := r.ensuredSystemSG(ctx) + if err != nil { + return false, err + } + if rec.SystemSGID == sgID { + return false, nil + } + + if _, err := r.svc.deps.Launch.VPC.ModifyNetworkInterfaceAttribute(ctx, &ec2.ModifyNetworkInterfaceAttributeInput{ + NetworkInterfaceId: aws.String(rec.SystemENIID), + Groups: aws.StringSlice([]string{sgID}), + }, utils.GlobalAccountID); err != nil { + return false, fmt.Errorf("rds: move the system NIC of %s onto %s: %w", rec.DBInstanceIdentifier, sgID, err) + } + // Recorded only once vpcd has accepted the change, so a failure above is + // retried next pass rather than remembered as done. + rec.SystemSGID = sgID + if _, err := updateJSONRevision(ctx, kv, DBInstanceKey(rec.DBInstanceIdentifier), rev, rec); err != nil { + return false, err + } + slog.InfoContext(ctx, "rds: system NIC moved onto the RDS system security group", + "dbInstance", rec.DBInstanceIdentifier, "eniId", rec.SystemENIID, "groupId", sgID) + return true, nil +} + +// The region's system security group, ensured on first use and then cached for +// the life of the process. +func (r *Reconciler) ensuredSystemSG(ctx context.Context) (string, error) { + r.systemSGMu.Lock() + defer r.systemSGMu.Unlock() + if r.systemSGID != "" { + return r.systemSGID, nil + } + deps := r.svc.deps.Launch + if deps.VPC == nil || deps.Config == nil { + return "", errors.New("rds reconciler: no VPC path is configured to place system NICs on their own security group") + } + refs, err := EnsureSystemVPC(ctx, deps.SystemVPC, &deps.Config.RDS, utils.GlobalAccountID, deps.Config.Region) + if err != nil { + return "", err + } + sgID, err := EnsureSystemSecurityGroup(ctx, deps.VPC, utils.GlobalAccountID, deps.Config.Region, refs.VpcID) + if err != nil { + return "", err + } + r.systemSGID = sgID + return sgID, nil +} + // An available instance whose payload is still staged bootstrapped against an // agent too old to acknowledge, so the ciphertext will sit there until an // operator acts. Only reported: deleting the payload on the grounds that a diff --git a/spinifex/handlers/rds/reconciler_test.go b/spinifex/handlers/rds/reconciler_test.go index bc35cc8d6..48b0c49e1 100644 --- a/spinifex/handlers/rds/reconciler_test.go +++ b/spinifex/handlers/rds/reconciler_test.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/rds" + "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/testutil" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" @@ -55,7 +56,7 @@ func newReconcileHarness(t *testing.T, opts ...func(*Deps)) *reconcileHarness { _, nc, _ := testutil.StartTestJetStream(t) state := &fakeInstanceState{state: instanceStateRunning} - deps := Deps{LoadCA: newTestCA(t), InstanceState: state} + deps := Deps{LoadCA: newTestCA(t), InstanceState: state, ServingCertKeyBits: testServingCertKeyBits} for _, opt := range opts { opt(&deps) } @@ -270,6 +271,95 @@ func TestReconciler_FallsBackToTheHeartbeatWhenVMStateIsUnwired(t *testing.T) { assert.Equal(t, StatusAvailable, status) } +// The launch surface the retrofit sweep needs: a system VPC to ensure the group +// in, and the ENI surface to move the NIC with. +func withSystemNICPath(enis *fakeENIs) func(*Deps) { + return func(d *Deps) { + d.Launch = LaunchDeps{ + Config: &config.Config{Region: testRegion}, + SystemVPC: (&fakeSystemVPC{}).deps(), + VPC: enis, + } + } +} + +func (h *reconcileHarness) systemSGOf(t *testing.T, id string) string { + t.Helper() + kv, err := h.svc.bucket(t.Context(), testAccountID) + require.NoError(t, err) + var rec DBInstanceRecord + found, err := getJSON(t.Context(), kv, DBInstanceKey(id), &rec) + require.NoError(t, err) + require.True(t, found) + return rec.SystemSGID +} + +// A DB VM launched before the RDS system group existed sits in the system VPC's +// default group, whose sole ingress rule admits every other member of itself. +func TestReconciler_MovesAnExistingSystemNICOntoTheRDSSystemGroup(t *testing.T) { + t.Parallel() + enis := &fakeENIs{} + h := newReconcileHarness(t, withSystemNICPath(enis)) + rec := healthyRecord() + rec.SystemENIID = "eni-sys01" + seedInstance(t, h.svc, rec) + + require.NoError(t, h.rec.reconcileOnce(t.Context())) + + require.Len(t, enis.modified, 1) + assert.Equal(t, "eni-sys01", aws.StringValue(enis.modified[0].NetworkInterfaceId)) + assert.Equal(t, []string{testSystemSG}, aws.StringValueSlice(enis.modified[0].Groups)) + assert.Equal(t, testSystemSG, h.systemSGOf(t, testDBID)) + + // The remediation yields its pass, so the record it wrote is not transitioned + // on a revision the status handler read before the move. + status, _ := h.statusOf(t, testDBID) + assert.Equal(t, StatusCreating, status) + + require.NoError(t, h.rec.reconcileOnce(t.Context())) + assert.Len(t, enis.modified, 1, "once the group is recorded the sweep is a string compare") + status, _ = h.statusOf(t, testDBID) + assert.Equal(t, StatusAvailable, status) +} + +// The group is regional, so the sweep must not describe and create one per DB +// instance per pass. +func TestReconciler_EnsuresTheSystemGroupOncePerProcess(t *testing.T) { + t.Parallel() + enis := &fakeENIs{} + h := newReconcileHarness(t, withSystemNICPath(enis)) + rec := healthyRecord() + rec.SystemENIID = "eni-sys01" + seedInstance(t, h.svc, rec) + + require.NoError(t, h.rec.reconcileOnce(t.Context())) + require.NoError(t, h.rec.reconcileOnce(t.Context())) + + assert.Len(t, enis.sgDescribed, 1) + assert.Len(t, enis.sgCreated, 1) +} + +// Recording the move before vpcd accepted it would leave the ENI in the default +// group with the record claiming otherwise, which no later pass would revisit. +func TestReconciler_RetriesTheSystemNICMoveAfterAFailure(t *testing.T) { + t.Parallel() + enis := &fakeENIs{modifyErr: errors.New("vpcd is not answering")} + h := newReconcileHarness(t, withSystemNICPath(enis)) + rec := healthyRecord() + rec.SystemENIID = "eni-sys01" + seedInstance(t, h.svc, rec) + + err := h.rec.reconcileOnce(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "vpcd is not answering") + assert.Empty(t, h.systemSGOf(t, testDBID)) + + enis.modifyErr = nil + require.NoError(t, h.rec.reconcileOnce(t.Context())) + assert.Len(t, enis.modified, 1) + assert.Equal(t, testSystemSG, h.systemSGOf(t, testDBID)) +} + // One node does the control work; the rest keep serving the API. A second // claimant must not also believe it is leader, or two nodes would transition // the same records. @@ -290,6 +380,37 @@ func TestReconciler_ElectsASingleLeader(t *testing.T) { assert.False(t, h.rec.acquireOrRefresh(t.Context())) } +func TestReconciler_RefreshesLeadershipOnAnIndependentLoop(t *testing.T) { + t.Parallel() + h := newReconcileHarness(t) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + h.rec.maintainLeadership(ctx, 5*time.Millisecond) + }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Error("leadership refresh did not stop after cancellation") + } + }) + + require.Eventually(t, h.rec.isLeader, time.Second, 5*time.Millisecond) + kv, err := h.rec.leaderBucket(t.Context()) + require.NoError(t, err) + entry, err := kv.Get(t.Context(), reconcilerLeaderKey) + require.NoError(t, err) + firstRevision := entry.Revision() + + require.Eventually(t, func() bool { + entry, err := kv.Get(t.Context(), reconcilerLeaderKey) + return err == nil && entry.Revision() > firstRevision + }, time.Second, 5*time.Millisecond, "the lease revision must advance without the reconcile loop driving it") +} + // A node that never won the lease must not delete the holder's key on shutdown. func TestReconciler_RelinquishOnlyReleasesItsOwnLease(t *testing.T) { t.Parallel() diff --git a/spinifex/handlers/rds/records.go b/spinifex/handlers/rds/records.go index 5c060d78c..c8e46776f 100644 --- a/spinifex/handlers/rds/records.go +++ b/spinifex/handlers/rds/records.go @@ -72,8 +72,12 @@ type DBInstanceRecord struct { // Where the customer ENI was placed, so a replace lands the new VM's ENI in // the same subnet and security groups without re-deriving them. - SubnetID string `json:"subnetId,omitempty"` - VpcID string `json:"vpcId,omitempty"` + SubnetID string `json:"subnetId,omitempty"` + VpcID string `json:"vpcId,omitempty"` + // The customer VPC's address range, which is the scope the guest's client + // authentication rules are generated against. Captured at create rather than + // resolved per boot, so a bootstrap fetch costs no VPC describe. + VpcCIDR string `json:"vpcCidr,omitempty"` VpcSecurityGroupIDs []string `json:"vpcSecurityGroupIds,omitempty"` DBSubnetGroupName string `json:"dbSubnetGroupName,omitempty"` DBParameterGroupName string `json:"dbParameterGroupName,omitempty"` @@ -92,6 +96,10 @@ type DBInstanceRecord struct { ENIID string `json:"eniId,omitempty"` // Disposable: a replace mints a new one, unlike the customer ENI. SystemENIID string `json:"systemEniId,omitempty"` + // The RDS system security group the system ENI carries. Recorded so the + // reconciler's one-time remediation is a string compare per pass rather than + // an ENI describe, and re-derived from the launch on every replace. + SystemSGID string `json:"systemSgId,omitempty"` // Stable across VM replace, so it serves as both the fallback endpoint and // a durable IP SAN on the serving cert. ENIPrivateIP string `json:"eniPrivateIp,omitempty"` diff --git a/spinifex/handlers/rds/replace.go b/spinifex/handlers/rds/replace.go index c4aa9dd2e..ac90cf8b5 100644 --- a/spinifex/handlers/rds/replace.go +++ b/spinifex/handlers/rds/replace.go @@ -132,6 +132,7 @@ func (s *Service) replaceInstanceVM(ctx context.Context, kv jetstream.KeyValue, if err := s.updateInstance(ctx, kv, rec.DBInstanceIdentifier, func(stored *DBInstanceRecord) { stored.InstanceID = launched.InstanceID stored.SystemENIID = launched.SystemENIID + stored.SystemSGID = launched.SystemSGID stored.DataVolumeSerial = launched.DataVolumeSerial stored.VMGeneration++ stored.FormatAuthorized = false @@ -145,6 +146,7 @@ func (s *Service) replaceInstanceVM(ctx context.Context, kv jetstream.KeyValue, // VM's identity on top of this one. rec.InstanceID = launched.InstanceID rec.SystemENIID = launched.SystemENIID + rec.SystemSGID = launched.SystemSGID rec.DataVolumeSerial = launched.DataVolumeSerial rec.VMGeneration++ rec.FormatAuthorized = false diff --git a/spinifex/handlers/rds/service.go b/spinifex/handlers/rds/service.go index 580638513..33cb12a59 100644 --- a/spinifex/handlers/rds/service.go +++ b/spinifex/handlers/rds/service.go @@ -23,6 +23,9 @@ type Deps struct { CAKeyPath string // Overrides the file-backed CA loader in tests. LoadCA CALoader + // Overrides the size of the RSA key each serving cert is minted with. Zero + // takes defaultServingCertKeyBits, which is what production runs. + ServingCertKeyBits int // The cluster master key the staged bootstrap password is encrypted under. // Mandatory: without it a create cannot stage a password at all, so it is // resolved at construction rather than per operation. diff --git a/spinifex/handlers/rds/servingcert.go b/spinifex/handlers/rds/servingcert.go index 7cd9e9ea0..05652ad9a 100644 --- a/spinifex/handlers/rds/servingcert.go +++ b/spinifex/handlers/rds/servingcert.go @@ -16,8 +16,8 @@ import ( // The lifetime is deliberately short: the cert is re-minted on every bootstrap // fetch and never persisted, so a leaked key ages out on its own. const ( - servingCertKeyBits = 2048 - servingCertLifetime = 90 * 24 * time.Hour + defaultServingCertKeyBits = 2048 + servingCertLifetime = 90 * 24 * time.Hour ) // Injected so tests mint from a throwaway CA, and so the file-backed @@ -30,6 +30,9 @@ type ServingCertRequest struct { DBInstanceIdentifier string PrivateIP string DNSName string + // Zero takes defaultServingCertKeyBits. Only tests set it, to buy back the + // keygen a 2048-bit key costs on every mint. + KeyBits int } // PEM-encoded, in memory only. @@ -52,7 +55,11 @@ func MintServingCert(caCert *x509.Certificate, caKey *rsa.PrivateKey, req Servin return nil, fmt.Errorf("rds serving cert: invalid private IP %q", req.PrivateIP) } - key, err := rsa.GenerateKey(rand.Reader, servingCertKeyBits) + bits := req.KeyBits + if bits <= 0 { + bits = defaultServingCertKeyBits + } + key, err := rsa.GenerateKey(rand.Reader, bits) if err != nil { return nil, fmt.Errorf("rds serving cert: generate key: %w", err) } diff --git a/spinifex/handlers/rds/snapshot_test.go b/spinifex/handlers/rds/snapshot_test.go index a503df9aa..761915381 100644 --- a/spinifex/handlers/rds/snapshot_test.go +++ b/spinifex/handlers/rds/snapshot_test.go @@ -49,12 +49,13 @@ func newSnapshotHarness(t *testing.T, agentFails bool) *snapshotHarness { stubDNSWriter(t, nc) h.svc = NewService(nc, testRegion).WithDeps(Deps{ - LoadCA: newTestCA(t), - MasterKey: testMasterKey, - Launch: h.launch.deps(), - Network: h.network, - IAM: testIAMProvider(h.iam), - Snapshots: h.snaps, + LoadCA: newTestCA(t), + MasterKey: testMasterKey, + Launch: h.launch.deps(), + Network: h.network, + IAM: testIAMProvider(h.iam), + Snapshots: h.snaps, + ServingCertKeyBits: testServingCertKeyBits, }) return h } diff --git a/spinifex/handlers/rds/vpc.go b/spinifex/handlers/rds/vpc.go index 99a2a1afd..72261221b 100644 --- a/spinifex/handlers/rds/vpc.go +++ b/spinifex/handlers/rds/vpc.go @@ -5,11 +5,20 @@ import ( "errors" "fmt" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/spinifex/config" handlers_systemvpc "github.com/mulgadc/spinifex/spinifex/handlers/systemvpc" "github.com/mulgadc/spinifex/spinifex/tags" ) +// The security-group surface the system ENI's group is ensured through. Narrow +// on purpose: this never authorizes a rule, so it holds no authorize verb. +type systemSecurityGroupProvisioner interface { + CreateSecurityGroup(ctx context.Context, input *ec2.CreateSecurityGroupInput, accountID string) (*ec2.CreateSecurityGroupOutput, error) + DescribeSecurityGroups(ctx context.Context, input *ec2.DescribeSecurityGroupsInput, accountID string) (*ec2.DescribeSecurityGroupsOutput, error) +} + // One shared RDS system VPC per region holds every DB VM's primary NIC. DB // instances are isolated by their own customer ENI and security group, so a VPC // per instance would add a NAT gateway and EIP without adding isolation. @@ -28,6 +37,12 @@ func SystemVPCName(region string) string { return "rds-system-" + region } +// One group per region, shared by every DB VM's system ENI. The name is the +// lookup key, so it must stay stable across releases too. +func SystemSecurityGroupName(region string) string { + return "rds-system-" + region +} + // cfg supplies the operator-overridable address space and subnet count; a nil // or unset cfg falls back to the defaults. func SystemVPCSpec(cfg *config.RDSConfig, region string) handlers_systemvpc.Spec { @@ -56,7 +71,10 @@ func SystemVPCSpec(cfg *config.RDSConfig, region string) handlers_systemvpc.Spec } // Idempotent. The private subnet the DB VMs sit in routes 0.0.0.0/0 to the -// VPC's NAT gateway, which is the agent's only egress to the gateway. +// VPC's NAT gateway, which is the agent's egress to the gateway wherever no +// management bridge exists. On a formed deployment one always does, and the +// agent reaches the gateway over it instead — so this path carries almost no +// required traffic. func EnsureSystemVPC(ctx context.Context, deps handlers_systemvpc.Deps, cfg *config.RDSConfig, accountID, region string) (*handlers_systemvpc.Refs, error) { if region == "" { return nil, errors.New("rds: EnsureSystemVPC empty region") @@ -70,3 +88,76 @@ func EnsureSystemVPC(ctx context.Context, deps handlers_systemvpc.Deps, cfg *con } return refs, nil } + +// The security group every DB VM's system ENI carries, found by name or created. +// Idempotent, and shared across the region: nothing deletes it per instance. +// +// No ingress rules are authorized, and none are needed. Nothing in the platform +// connects to a DB VM's system ENI — the agent long-polls outbound, the control +// plane never dials the engine, and the gateway is reached over the management +// bridge. The ACLs are allow-related, so an agent-initiated connection's return +// traffic is matched by conntrack rather than by an ingress rule. +// +// The whole of the fix is not landing in the VPC's default group, whose one +// ingress rule admits every other member of itself — which is every DB VM in +// the deployment, across every account. +func EnsureSystemSecurityGroup(ctx context.Context, sgs systemSecurityGroupProvisioner, accountID, region, vpcID string) (string, error) { + if region == "" { + return "", errors.New("rds: EnsureSystemSecurityGroup empty region") + } + if vpcID == "" { + return "", errors.New("rds: EnsureSystemSecurityGroup empty vpc id") + } + name := SystemSecurityGroupName(region) + + out, err := sgs.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{ + Filters: []*ec2.Filter{ + {Name: aws.String("group-name"), Values: aws.StringSlice([]string{name})}, + {Name: aws.String("vpc-id"), Values: aws.StringSlice([]string{vpcID})}, + }, + }, accountID) + if err != nil { + return "", fmt.Errorf("rds: describe the system security group %s: %w", name, err) + } + var matches []*ec2.SecurityGroup + if out != nil { + for _, group := range out.SecurityGroups { + if group == nil || aws.StringValue(group.GroupId) == "" { + return "", fmt.Errorf("rds: describe the system security group %s: group has no id", name) + } + matches = append(matches, group) + } + } + if len(matches) > 1 { + return "", fmt.Errorf("rds: describe the system security group %s: found %d groups, want exactly one", name, len(matches)) + } + if len(matches) == 1 { + group := matches[0] + id := aws.StringValue(group.GroupId) + if len(group.IpPermissions) != 0 { + return "", fmt.Errorf("rds: system security group %s (%s) has ingress rules; refusing to attach DB system NICs", name, id) + } + return id, nil + } + + created, err := sgs.CreateSecurityGroup(ctx, &ec2.CreateSecurityGroupInput{ + GroupName: aws.String(name), + Description: aws.String("RDS management NIC security group; no ingress"), + VpcId: aws.String(vpcID), + TagSpecifications: []*ec2.TagSpecification{{ + ResourceType: aws.String("security-group"), + Tags: []*ec2.Tag{ + {Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByRDS)}, + {Key: aws.String(rdsSystemVPCTagKey), Value: aws.String(SystemVPCName(region))}, + {Key: aws.String(rdsSystemVPCRoleTagKey), Value: aws.String(rdsSystemVPCRolePrefix)}, + }, + }}, + }, accountID) + if err != nil { + return "", fmt.Errorf("rds: create the system security group %s: %w", name, err) + } + if created == nil || aws.StringValue(created.GroupId) == "" { + return "", fmt.Errorf("rds: create the system security group %s: no group id returned", name) + } + return aws.StringValue(created.GroupId), nil +} diff --git a/spinifex/handlers/sysinstance/launcher.go b/spinifex/handlers/sysinstance/launcher.go index 113f49bd3..f3cfaa63e 100644 --- a/spinifex/handlers/sysinstance/launcher.go +++ b/spinifex/handlers/sysinstance/launcher.go @@ -100,6 +100,11 @@ type ExtraENIInput struct { // LB VM lives in the system CP VPC but fronts a customer-VPC ENI). Empty // falls back to the primary AccountID — back-compat for same-account extras. AccountID string `json:"account_id,omitempty"` + // DeleteOnTermination stamps an explicit value on the attached ENI record, + // for an ENI that has to outlive the VM it is plugged into (an RDS endpoint + // ENI across a class-change replace). Nil keeps the attach-time default, + // which deletes the ENI with the VM. + DeleteOnTermination *bool `json:"delete_on_termination,omitempty"` } // NICConfig describes a single network interface for a BootDirect microVM. diff --git a/spinifex/network/reconcile/apply_guest_port_backoff_test.go b/spinifex/network/reconcile/apply_guest_port_backoff_test.go index 1f97b8893..0c2e360e1 100644 --- a/spinifex/network/reconcile/apply_guest_port_backoff_test.go +++ b/spinifex/network/reconcile/apply_guest_port_backoff_test.go @@ -1,3 +1,7 @@ +//test:in-package — the backoff is unexported state (guestPortBackoffBase / +//guestPortBackoffMax and the per-port failure map), and the tests drive +//ensureGuestPortDatapath and recordPortFailure directly. + package reconcile import ( diff --git a/spinifex/services/spinifexui/frontend/dist/assets/index.css b/spinifex/services/spinifexui/frontend/dist/assets/index.css index 7c011b2d3..7148c6ed3 100644 --- a/spinifex/services/spinifexui/frontend/dist/assets/index.css +++ b/spinifex/services/spinifexui/frontend/dist/assets/index.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:#ffe2e2;--color-red-800:#9f0712;--color-red-900:#82181a;--color-amber-400:#fcbb00;--color-amber-500:#f99c00;--color-amber-700:#b75000;--color-yellow-100:#fef9c2;--color-yellow-800:#874b00;--color-yellow-900:#733e0a;--color-green-100:#dcfce7;--color-green-600:#00a544;--color-green-800:#016630;--color-green-900:#0d542b;--color-emerald-400:#00d294;--color-emerald-500:#00bb7f;--color-emerald-700:#007956;--color-blue-500:#3080ff;--color-gray-100:#f3f4f6;--color-gray-800:#1e2939;--color-zinc-100:#f4f4f5;--color-zinc-400:#9f9fa9;--color-zinc-500:#71717b;--color-zinc-800:#27272a;--color-black:#000;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--tracking-widest:.1em;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 2px);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-foreground:var(--foreground)}@supports (color:lab(0% 0 0)){:root,:host{--color-red-100:lab(92.243% 10.2865 3.83865);--color-red-800:lab(33.7174% 55.8993 41.0293);--color-red-900:lab(28.5139% 44.5539 29.0463);--color-amber-400:lab(80.1641% 16.6016 99.2089);--color-amber-500:lab(72.7183% 31.8672 97.9407);--color-amber-700:lab(47.2709% 42.9082 69.2966);--color-yellow-100:lab(97.3564% -4.51407 27.344);--color-yellow-800:lab(38.7484% 23.5833 51.4916);--color-yellow-900:lab(32.3865% 21.1273 38.5959);--color-green-100:lab(96.1861% -13.8464 6.52365);--color-green-600:lab(59.0978% -58.6621 41.2579);--color-green-800:lab(37.4616% -36.7971 22.9692);--color-green-900:lab(30.797% -29.6927 17.382);--color-emerald-400:lab(75.0771% -60.7313 19.4147);--color-emerald-500:lab(66.9756% -58.27 19.5419);--color-emerald-700:lab(44.4871% -41.0396 11.0361);--color-blue-500:lab(54.1736% 13.3369 -74.6839);--color-gray-100:lab(96.1596% -.0823438 -1.13575);--color-gray-800:lab(16.1051% -1.18239 -11.7533);--color-zinc-100:lab(96.1634% .0993311 -.364041);--color-zinc-400:lab(65.6464% 1.53497 -5.42429);--color-zinc-500:lab(47.8878% 1.65477 -5.77283);--color-zinc-800:lab(15.7305% .613764 -2.16959)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}*{scrollbar-width:thin}html,body{overscroll-behavior:none}body{background-color:var(--background);width:100%;min-height:100svh;color:var(--foreground);font-family:var(--font-sans), sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.font-mono{font-family:var(--font-mono), monospace}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.\@container\/field-group{container:field-group/inline-size}.\@container{container-type:inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-3\.5{top:calc(var(--spacing) * 3.5)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:0}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.left-0{left:0}.left-1\/2{left:50%}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.order-first{order:-9999}.order-last{order:9999}.col-span-2{grid-column:span 2/span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-0{margin-inline:0}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3\.5{margin-inline:calc(var(--spacing) * 3.5)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-auto{margin-right:auto}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.ml-1{margin-left:var(--spacing)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.field-sizing-content{field-sizing:content}.aspect-square{aspect-ratio:1}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-\[1\.2rem\]{width:1.2rem;height:1.2rem}.size-full{width:100%;height:100%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-\[calc\(--spacing\(4\.75\)\)\]{height:calc(calc(var(--spacing) * 4.75))}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-svh{height:100svh}.max-h-\(--available-height\){max-height:var(--available-height)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[85vh\]{max-height:85vh}.max-h-\[min\(calc\(--spacing\(72\)---spacing\(9\)\)\,calc\(var\(--available-height\)---spacing\(9\)\)\)\]{max-height:min(calc(calc(var(--spacing) * 72) - calc(var(--spacing) * 9)), calc(var(--available-height) - calc(var(--spacing) * 9)))}.min-h-0{min-height:0}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-svh{min-height:100svh}.w-\(--anchor-width\){width:var(--anchor-width)}.w-\(--sidebar-width\){width:var(--sidebar-width)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\(--available-width\){max-width:var(--available-width)}.max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-\[calc\(var\(--anchor-width\)\+--spacing\(7\)\)\]{min-width:calc(var(--anchor-width) + calc(var(--spacing) * 7))}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.origin-\(--transform-origin\){transform-origin:var(--transform-origin)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[calc\(-50\%-2px\)\]{--tw-translate-y:calc(-50% - 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-none{translate:none}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-3d{scale:var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)}.rotate-0{rotate:0deg}.rotate-45{rotate:45deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:var(--spacing)}.scroll-py-1{scroll-padding-block:var(--spacing)}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}:where(.-space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-\[calc\(var\(--radius-sm\)\+2px\)\]{border-radius:calc(var(--radius-sm) + 2px)}.rounded-\[calc\(var\(--radius-sm\)-2px\)\]{border-radius:calc(var(--radius-sm) - 2px)}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-lg{border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-lg{border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-bs{border-block-start-style:var(--tw-border-style);border-block-start-width:1px}.border-be{border-block-end-style:var(--tw-border-style);border-block-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-border{border-color:var(--border)}.border-destructive{border-color:var(--destructive)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/30{border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/10{border-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/20{border-color:color-mix(in oklab, var(--foreground) 20%, transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-primary{border-color:var(--primary)}.border-sidebar-border{border-color:var(--sidebar-border)}.border-tactical-amber\/40{border-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.border-tactical-amber\/40{border-color:color-mix(in oklab, var(--tactical-amber) 40%, transparent)}}.border-transparent{border-color:#0000}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-background{background-color:var(--background)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-border,.bg-border\/50{background-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.bg-border\/50{background-color:color-mix(in oklab, var(--border) 50%, transparent)}}.bg-card{background-color:var(--card)}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-foreground{background-color:var(--foreground)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-100{background-color:var(--color-green-100)}.bg-input\/20{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.bg-input\/20{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/10{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.bg-muted-foreground\/10{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}.bg-muted\/20{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/20{background-color:color-mix(in oklab, var(--muted) 20%, transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/30{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/50{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-sidebar-border{background-color:var(--sidebar-border)}.bg-tactical-amber\/5{background-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.bg-tactical-amber\/5{background-color:color-mix(in oklab, var(--tactical-amber) 5%, transparent)}}.bg-transparent{background-color:#0000}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-clip-padding{background-clip:padding-box}.bg-repeat{background-repeat:repeat}.mask-no-clip{-webkit-mask-clip:no-clip;mask-clip:no-clip}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.fill-foreground{fill:var(--foreground)}.object-cover{object-fit:cover}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-0{padding-top:0}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:0}.pb-1{padding-bottom:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.text-\[0\.625rem\]{font-size:.625rem}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.text-wrap{text-wrap:wrap}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-700{color:var(--color-amber-700)}.text-background{color:var(--background)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-emerald-700{color:var(--color-emerald-700)}.text-foreground{color:var(--foreground)}.text-gray-800{color:var(--color-gray-800)}.text-green-600{color:var(--color-green-600)}.text-green-800{color:var(--color-green-800)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground,.text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.text-sidebar-foreground\/70{color:color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)}}.text-tactical-amber{color:var(--tactical-amber)}.text-tactical-cyan{color:var(--tactical-cyan)}.text-tactical-green{color:var(--tactical-green)}.text-yellow-800{color:var(--color-yellow-800)}.text-zinc-500{color:var(--color-zinc-500)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.underline-offset-3{text-underline-offset:3px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-50{opacity:.5}.bg-blend-color{background-blend-mode:color}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_0_1px_hsl\(var\(--sidebar-border\)\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-border)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.inset-ring{--tw-inset-ring-shadow:inset 0 0 0 1px var(--tw-inset-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-background{--tw-ring-color:var(--background)}.ring-foreground\/10{--tw-ring-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.ring-foreground\/10{--tw-ring-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}.group-focus-within\/menu-item\:opacity-100:is(:where(.group\/menu-item):focus-within *){opacity:1}@media (hover:hover){.group-hover\/menu-item\:opacity-100:is(:where(.group\/menu-item):hover *){opacity:1}}.group-focus\/dropdown-menu-item\:text-accent-foreground:is(:where(.group\/dropdown-menu-item):focus *){color:var(--accent-foreground)}.group-has-data-\[orientation\=horizontal\]\/field\:text-balance:is(:where(.group\/field):has([data-orientation=horizontal]) *){text-wrap:balance}.group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--spacing) * 8)}.group-has-data-\[size\=lg\]\/avatar-group\:size-10:is(:where(.group\/avatar-group):has([data-size=lg]) *){width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.group-has-data-\[size\=sm\]\/avatar-group\:size-6:is(:where(.group\/avatar-group):has([data-size=sm]) *){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.group-has-data-\[slot\=combobox-clear\]\/input-group\:hidden:is(:where(.group\/input-group):has([data-slot=combobox-clear]) *){display:none}.group-has-\[\>input\]\/input-group\:pt-2:is(:where(.group\/input-group):has(>input) *){padding-top:calc(var(--spacing) * 2)}.group-has-\[\>input\]\/input-group\:pb-2:is(:where(.group\/input-group):has(>input) *){padding-bottom:calc(var(--spacing) * 2)}.group-data-empty\/combobox-content\:flex:is(:where(.group\/combobox-content)[data-empty] *){display:flex}.group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.group)[data-collapsible=icon] *){margin-top:calc(var(--spacing) * -8)}.group-data-\[collapsible\=icon\]\:hidden:is(:where(.group)[data-collapsible=icon] *){display:none}.group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--spacing) * 8)!important;height:calc(var(--spacing) * 8)!important}.group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)))}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)) + 2px)}.group-data-\[collapsible\=icon\]\:\[scrollbar-width\:none\]:is(:where(.group)[data-collapsible=icon] *){scrollbar-width:none}.group-data-\[collapsible\=icon\]\:overflow-hidden:is(:where(.group)[data-collapsible=icon] *){overflow:hidden}.group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.group)[data-collapsible=icon] *){padding:0!important}.group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing) * 2)!important}.group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.group)[data-collapsible=icon] *){opacity:0}.group-data-\[collapsible\=offExamples\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){right:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){left:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:w-0:is(:where(.group)[data-collapsible=offExamples] *){width:0}.group-data-\[collapsible\=offExamples\]\:translate-x-0:is(:where(.group)[data-collapsible=offExamples] *){--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *),.group-data-\[disabled\=true\]\/field\:opacity-50:is(:where(.group\/field)[data-disabled=true] *),.group-data-\[disabled\=true\]\/input-group\:opacity-50:is(:where(.group\/input-group)[data-disabled=true] *){opacity:.5}.group-data-\[panel-open\]\:rotate-180:is(:where(.group)[data-panel-open] *){rotate:180deg}.group-data-\[side\=left\]\:-right-4:is(:where(.group)[data-side=left] *){right:calc(var(--spacing) * -4)}.group-data-\[side\=left\]\:border-r:is(:where(.group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.group-data-\[side\=right\]\:left-0:is(:where(.group)[data-side=right] *){left:0}.group-data-\[side\=right\]\:rotate-180:is(:where(.group)[data-side=right] *){rotate:180deg}.group-data-\[side\=right\]\:border-l:is(:where(.group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.group-data-\[size\=default\]\/avatar\:size-2\.5:is(:where(.group\/avatar)[data-size=default] *){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.group-data-\[size\=lg\]\/avatar\:size-3:is(:where(.group\/avatar)[data-size=lg] *){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=sm\]\/alert-dialog-content\:grid:is(:where(.group\/alert-dialog-content)[data-size=sm] *){display:grid}.group-data-\[size\=sm\]\/alert-dialog-content\:grid-cols-2:is(:where(.group\/alert-dialog-content)[data-size=sm] *){grid-template-columns:repeat(2,minmax(0,1fr))}.group-data-\[size\=sm\]\/avatar\:size-2:is(:where(.group\/avatar)[data-size=sm] *){width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:text-xs:is(:where(.group\/avatar)[data-size=sm] *){font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.group-data-\[size\=sm\]\/card\:px-3:is(:where(.group\/card)[data-size=sm] *){padding-inline:calc(var(--spacing) * 3)}.group-data-\[variant\=floating\]\:rounded-lg:is(:where(.group)[data-variant=floating] *){border-radius:calc(var(--radius) + 2px)}.group-data-\[variant\=floating\]\:shadow-sm:is(:where(.group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-1:is(:where(.group)[data-variant=floating] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-sidebar-border:is(:where(.group)[data-variant=floating] *){--tw-ring-color:var(--sidebar-border)}.group-data-\[variant\=outline\]\/field-group\:-mb-2:is(:where(.group\/field-group)[data-variant=outline] *){margin-bottom:calc(var(--spacing) * -2)}@media (hover:hover){.peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-data-active\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button)[data-active]~*){color:var(--sidebar-accent-foreground)}.peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.peer\/menu-button)[data-size=default]~*){top:calc(var(--spacing) * 1.5)}.peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.peer\/menu-button)[data-size=lg]~*){top:calc(var(--spacing) * 2.5)}.peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.peer\/menu-button)[data-size=sm]~*){top:var(--spacing)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-6::file-selector-button{height:calc(var(--spacing) * 6)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-xs\/relaxed::file-selector-button{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--spacing) * -2)}.after\:inset-0:after{content:var(--tw-content);inset:0}.after\:inset-y-0:after{content:var(--tw-content);inset-block:0}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.after\:rounded-full:after{content:var(--tw-content);border-radius:2147483647px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-border:after{content:var(--tw-content);border-color:var(--border)}.after\:mix-blend-darken:after{content:var(--tw-content);mix-blend-mode:darken}.group-data-\[collapsible\=offExamples\]\:after\:left-full:is(:where(.group)[data-collapsible=offExamples] *):after{content:var(--tw-content);left:100%}.last\:mt-0:last-child{margin-top:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.autofill\:\[-webkit-text-fill-color\:var\(--color-foreground\)\]:autofill{-webkit-text-fill-color:var(--color-foreground)}.autofill\:\[transition\:background-color_9999s_ease-in-out_0s\]:autofill{transition:background-color 9999s ease-in-out}.focus-within\:border-ring:focus-within{border-color:var(--ring)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}@media (hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/20:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/20:hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.hover\:bg-input\/40:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/40:hover{background-color:color-mix(in oklab, var(--input) 40%, transparent)}}.hover\:bg-input\/50:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/50:hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.hover\:bg-muted:hover{background-color:var(--muted)}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-\[0_0_0_1px_hsl\(var\(--sidebar-accent\)\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-accent)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:group-data-\[collapsible\=offExamples\]\:bg-sidebar:hover:is(:where(.group)[data-collapsible=offExamples] *){background-color:var(--sidebar)}.hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus,:is(.focus\:\*\*\:text-accent-foreground:focus *),:is(.not-data-\[variant\=destructive\]\:focus\:\*\*\:text-accent-foreground:not([data-variant=destructive]):focus *){color:var(--accent-foreground)}.focus-visible\:border-destructive\/40:focus-visible{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:border-destructive\/40:focus-visible{border-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible,.focus-visible\:ring-\[2px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:border-inherit:focus-within{border-color:inherit}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:ring-0:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-disabled\:pointer-events-none:has(:disabled){pointer-events:none}.has-disabled\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-disabled\:opacity-50:has(:disabled){opacity:.5}.has-aria-invalid\:border-destructive:has([aria-invalid=true]){border-color:var(--destructive)}.has-aria-invalid\:ring-2:has([aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.has-data-\[align\=block-end\]\:rounded-md:has([data-align=block-end]),.has-data-\[align\=block-start\]\:rounded-md:has([data-align=block-start]){border-radius:var(--radius)}.has-data-\[icon\=inline-end\]\:pr-1\.5:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-end\]\:pr-2:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 2)}.has-data-\[icon\=inline-start\]\:pl-1\.5:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-start\]\:pl-2:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 2)}.has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_auto_1fr\]:has([data-slot=alert-dialog-media]){grid-template-rows:auto auto 1fr}.has-data-\[slot\=alert-dialog-media\]\:gap-x-4:has([data-slot=alert-dialog-media]){column-gap:calc(var(--spacing) * 4)}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-data-\[slot\=card-description\]\:grid-rows-\[auto_auto\]:has([data-slot=card-description]){grid-template-rows:auto auto}.has-data-\[slot\=combobox-chip\]\:px-1:has([data-slot=combobox-chip]){padding-inline:var(--spacing)}.has-data-\[slot\=combobox-chip-remove\]\:pr-0:has([data-slot=combobox-chip-remove]){padding-right:0}.has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:border-ring:has([data-slot=input-group-control]:focus-visible){border-color:var(--ring)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-2:has([data-slot=input-group-control]:focus-visible){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:border-destructive:has([data-slot][aria-invalid=true]){border-color:var(--destructive)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-2:has([data-slot][aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-\[textarea\]\:rounded-md:has(:is(textarea)){border-radius:var(--radius)}.has-\[\>\[data-align\=block-end\]\]\:h-auto:has(>[data-align=block-end]){height:auto}.has-\[\>\[data-align\=block-end\]\]\:flex-col:has(>[data-align=block-end]){flex-direction:column}.has-\[\>\[data-align\=block-start\]\]\:h-auto:has(>[data-align=block-start]){height:auto}.has-\[\>\[data-align\=block-start\]\]\:flex-col:has(>[data-align=block-start]){flex-direction:column}.has-\[\>\[data-slot\=checkbox-group\]\]\:gap-3:has(>[data-slot=checkbox-group]){gap:calc(var(--spacing) * 3)}.has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}.has-\[\>\[data-slot\=field\]\]\:w-full:has(>[data-slot=field]){width:100%}.has-\[\>\[data-slot\=field\]\]\:flex-col:has(>[data-slot=field]){flex-direction:column}.has-\[\>\[data-slot\=field\]\]\:rounded-md:has(>[data-slot=field]){border-radius:var(--radius)}.has-\[\>\[data-slot\=field\]\]\:border:has(>[data-slot=field]){border-style:var(--tw-border-style);border-width:1px}.has-\[\>\[data-slot\=radio-group\]\]\:gap-3:has(>[data-slot=radio-group]){gap:calc(var(--spacing) * 3)}.has-\[\>button\]\:mr-\[-0\.275rem\]:has(>button){margin-right:-.275rem}.has-\[\>button\]\:ml-\[-0\.275rem\]:has(>button){margin-left:-.275rem}.has-\[\>img\:first-child\]\:pt-0:has(>img:first-child){padding-top:0}.has-\[\>kbd\]\:mr-\[-0\.275rem\]:has(>kbd){margin-right:-.275rem}.has-\[\>kbd\]\:ml-\[-0\.275rem\]:has(>kbd){margin-left:-.275rem}.has-\[\>svg\]\:p-0:has(>svg){padding:0}.has-\[\>textarea\]\:h-auto:has(>textarea){height:auto}.aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.aria-expanded\:bg-muted[aria-expanded=true]{background-color:var(--muted)}.aria-expanded\:bg-secondary[aria-expanded=true]{background-color:var(--secondary)}.aria-expanded\:text-foreground[aria-expanded=true]{color:var(--foreground)}.aria-expanded\:text-secondary-foreground[aria-expanded=true]{color:var(--secondary-foreground)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-0[aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-2[aria-invalid=true],.aria-invalid\:ring-\[2px\][aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-active\:bg-sidebar-accent[data-active]{background-color:var(--sidebar-accent)}.data-active\:font-medium[data-active]{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.data-active\:text-sidebar-accent-foreground[data-active]{color:var(--sidebar-accent-foreground)}.data-closed\:overflow-hidden[data-closed]{overflow:hidden}.data-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.data-disabled\:opacity-50[data-disabled]{opacity:.5}.data-empty\:p-0[data-empty]{padding:0}.data-ending-style\:opacity-0[data-ending-style]{opacity:0}.data-highlighted\:bg-accent[data-highlighted]{background-color:var(--accent)}.data-highlighted\:text-accent-foreground[data-highlighted],:is(.not-data-\[variant\=destructive\]\:data-highlighted\:\*\*\:text-accent-foreground:not([data-variant=destructive])[data-highlighted] *){color:var(--accent-foreground)}.data-inset\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-open\:bg-accent[data-open]{background-color:var(--accent)}.data-open\:text-accent-foreground[data-open]{color:var(--accent-foreground)}.data-open\:opacity-100[data-open]{opacity:1}@media (hover:hover){.data-open\:hover\:bg-sidebar-accent[data-open]:hover{background-color:var(--sidebar-accent)}.data-open\:hover\:text-sidebar-accent-foreground[data-open]:hover{color:var(--sidebar-accent-foreground)}}.data-placeholder\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-pressed\:bg-transparent[data-pressed]{background-color:#0000}.data-selected\:border-primary[data-selected]{border-color:var(--primary)}.data-selected\:text-foreground[data-selected]{color:var(--foreground)}.data-starting-style\:opacity-0[data-starting-style]{opacity:0}.data-\[chips\=true\]\:min-w-\(--anchor-width\)[data-chips=true]{min-width:var(--anchor-width)}.data-\[invalid\=true\]\:text-destructive[data-invalid=true]{color:var(--destructive)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[orientation\=vertical\]\:self-stretch[data-orientation=vertical]{align-self:stretch}.data-\[side\=bottom\]\:inset-x-0[data-side=bottom]{inset-inline:0}.data-\[side\=bottom\]\:top-1[data-side=bottom]{top:var(--spacing)}.data-\[side\=bottom\]\:bottom-0[data-side=bottom]{bottom:0}.data-\[side\=bottom\]\:h-auto[data-side=bottom]{height:auto}.data-\[side\=bottom\]\:border-t[data-side=bottom]{border-top-style:var(--tw-border-style);border-top-width:1px}.data-\[side\=left\]\:inset-y-0[data-side=left]{inset-block:0}.data-\[side\=left\]\:top-1\/2\![data-side=left]{top:50%!important}.data-\[side\=left\]\:-right-1[data-side=left]{right:calc(var(--spacing) * -1)}.data-\[side\=left\]\:left-0[data-side=left]{left:0}.data-\[side\=left\]\:h-full[data-side=left]{height:100%}.data-\[side\=left\]\:w-3\/4[data-side=left]{width:75%}.data-\[side\=left\]\:-translate-y-1\/2[data-side=left]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:border-r[data-side=left]{border-right-style:var(--tw-border-style);border-right-width:1px}.data-\[side\=right\]\:inset-y-0[data-side=right]{inset-block:0}.data-\[side\=right\]\:top-1\/2\![data-side=right]{top:50%!important}.data-\[side\=right\]\:right-0[data-side=right]{right:0}.data-\[side\=right\]\:-left-1[data-side=right]{left:calc(var(--spacing) * -1)}.data-\[side\=right\]\:h-full[data-side=right]{height:100%}.data-\[side\=right\]\:w-3\/4[data-side=right]{width:75%}.data-\[side\=right\]\:-translate-y-1\/2[data-side=right]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:border-l[data-side=right]{border-left-style:var(--tw-border-style);border-left-width:1px}.data-\[side\=top\]\:inset-x-0[data-side=top]{inset-inline:0}.data-\[side\=top\]\:top-0[data-side=top]{top:0}.data-\[side\=top\]\:-bottom-2\.5[data-side=top]{bottom:calc(var(--spacing) * -2.5)}.data-\[side\=top\]\:h-auto[data-side=top]{height:auto}.data-\[side\=top\]\:border-b[data-side=top]{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.data-\[size\=default\]\:h-7[data-size=default]{height:calc(var(--spacing) * 7)}.data-\[size\=default\]\:max-w-xs[data-size=default]{max-width:var(--container-xs)}.data-\[size\=lg\]\:size-10[data-size=lg]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.data-\[size\=md\]\:text-xs[data-size=md]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.data-\[size\=sm\]\:size-6[data-size=sm]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:h-6[data-size=sm]{height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:max-w-64[data-size=sm]{max-width:calc(var(--spacing) * 64)}.data-\[size\=sm\]\:gap-3[data-size=sm]{gap:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:py-3[data-size=sm]{padding-block:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:text-xs[data-size=sm]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}:is(.\*\:data-\[slot\=avatar\]\:ring-2>*)[data-slot=avatar]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\:data-\[slot\=avatar\]\:ring-background>*)[data-slot=avatar]{--tw-ring-color:var(--background)}.data-\[slot\=checkbox-group\]\:gap-3[data-slot=checkbox-group]{gap:calc(var(--spacing) * 3)}:is(.\*\:data-\[slot\=field\]\:p-2>*)[data-slot=field]{padding:calc(var(--spacing) * 2)}:is(.\*\:data-\[slot\=field-group\]\:gap-4>*)[data-slot=field-group]{gap:calc(var(--spacing) * 4)}:is(.\*\:data-\[slot\=input-group\]\:m-1>*)[data-slot=input-group]{margin:var(--spacing)}:is(.\*\:data-\[slot\=input-group\]\:mb-0>*)[data-slot=input-group]{margin-bottom:0}:is(.\*\:data-\[slot\=input-group\]\:h-7>*)[data-slot=input-group]{height:calc(var(--spacing) * 7)}:is(.\*\:data-\[slot\=input-group\]\:border-none>*)[data-slot=input-group]{--tw-border-style:none;border-style:none}:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}:is(.\*\:data-\[slot\=input-group\]\:shadow-none>*)[data-slot=input-group]{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-\[calc\(var\(--radius-sm\)-2px\)\] *)[data-slot=kbd]{border-radius:calc(var(--radius-sm) - 2px)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-md *)[data-slot=kbd]{border-radius:var(--radius)}:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}:is(.\*\*\:data-\[slot\=kbd\]\:px-1 *)[data-slot=kbd]{padding-inline:var(--spacing)}:is(.\*\*\:data-\[slot\=kbd\]\:text-\[0\.625rem\] *)[data-slot=kbd]{font-size:.625rem}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-1\.5>*)[data-slot=select-value]{gap:calc(var(--spacing) * 1.5)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}.data-\[variant\=label\]\:text-xs\/relaxed[data-variant=label]{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.data-\[variant\=legend\]\:text-sm[data-variant=legend]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.nth-last-2\:-mt-1:nth-last-child(2){margin-top:calc(var(--spacing) * -1)}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-backdrop-filter\:backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}}@media (width>=40rem){.sm\:mr-auto{margin-right:auto}.sm\:flex{display:flex}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[1fr_1fr_1fr_auto\]{grid-template-columns:1fr 1fr 1fr auto}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:self-center{align-self:center}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:row-span-2:is(:where(.group\/alert-dialog-content)[data-size=default] *){grid-row:span 2/span 2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:place-items-start:is(:where(.group\/alert-dialog-content)[data-size=default] *){place-items:start}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:text-left:is(:where(.group\/alert-dialog-content)[data-size=default] *){text-align:left}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:group-has-data-\[slot\=alert-dialog-media\]\/alert-dialog-content\:col-start-2:is(:where(.group\/alert-dialog-content)[data-size=default] *):is(:where(.group\/alert-dialog-content):has([data-slot=alert-dialog-media]) *){grid-column-start:2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_1fr\]:is(:where(.group\/alert-dialog-content)[data-size=default] *):has([data-slot=alert-dialog-media]){grid-template-rows:auto 1fr}.data-\[side\=left\]\:sm\:max-w-sm[data-side=left],.data-\[side\=right\]\:sm\:max-w-sm[data-side=right],.data-\[size\=default\]\:sm\:max-w-sm[data-size=default]{max-width:var(--container-sm)}}@media (width>=48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.md\:text-pretty{text-wrap:pretty}.md\:opacity-0{opacity:0}.md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.peer)[data-variant=inset]~*){margin:calc(var(--spacing) * 2)}.md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.peer)[data-variant=inset]~*){margin-left:0}.md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.peer)[data-variant=inset]~*):is(:where(.peer)[data-state=collapsed]~*){margin-left:calc(var(--spacing) * 2)}.md\:after\:hidden:after{content:var(--tw-content);display:none}}@media (width>=64rem){.lg\:block{display:block}}@media (width>=80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@container field-group (width>=28rem){.\@md\/field-group\:flex-row{flex-direction:row}.\@md\/field-group\:items-center{align-items:center}.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}}.dark\:scale-0:where(.dark,.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:scale-100:where(.dark,.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:-rotate-90:where(.dark,.dark *){rotate:-90deg}.dark\:rotate-0:where(.dark,.dark *){rotate:0deg}.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-green-900:where(.dark,.dark *){background-color:var(--color-green-900)}.dark\:bg-input\/30:where(.dark,.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-input\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.dark\:bg-muted\/30:where(.dark,.dark *){background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-muted\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.dark\:bg-popover:where(.dark,.dark *){background-color:var(--popover)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-yellow-900:where(.dark,.dark *){background-color:var(--color-yellow-900)}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:invert:where(.dark,.dark *){--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.dark\:after\:mix-blend-lighten:where(.dark,.dark *):after{content:var(--tw-content);mix-blend-mode:lighten}@media (hover:hover){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--destructive) 30%, transparent)}}.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}}.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-2\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.group-data-\[collapsible\=icon\]\:\[\&\:\:-webkit-scrollbar\]\:hidden:is(:where(.group)[data-collapsible=icon] *)::-webkit-scrollbar{display:none}.\[\.border-b\]\:pb-2.border-b{padding-bottom:calc(var(--spacing) * 2)}.\[\.border-b\]\:pb-4.border-b{padding-bottom:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-b\]\:pb-3:is(:where(.group\/card)[data-size=sm] *).border-b{padding-bottom:calc(var(--spacing) * 3)}.\[\.border-t\]\:pt-2.border-t{padding-top:calc(var(--spacing) * 2)}.\[\.border-t\]\:pt-4.border-t{padding-top:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-t\]\:pt-3:is(:where(.group\/card)[data-size=sm] *).border-t{padding-top:calc(var(--spacing) * 3)}:is(.\*\:\[a\]\:underline>*):is(a){text-decoration-line:underline}:is(.\*\:\[a\]\:underline-offset-3>*):is(a){text-underline-offset:3px}@media (hover:hover){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[a\]\:hover\:bg-muted:is(a):hover{background-color:var(--muted)}.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.\[a\]\:hover\:text-muted-foreground:is(a):hover{color:var(--muted-foreground)}:is(.\*\:\[a\]\:hover\:text-foreground>*):is(a):hover{color:var(--foreground)}}:is(.\*\:\[img\:first-child\]\:rounded-t-lg>*):is(img:first-child){border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}:is(.\*\:\[img\:last-child\]\:rounded-b-lg>*):is(img:last-child){border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)}:is(.\*\:\[svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4>*):is(svg:not([class*=size-])){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>\*\]\:w-full>*{width:100%}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\*\]\:w-auto>*{width:auto}}.\[\&\>\.sr-only\]\:w-auto>.sr-only{width:auto}.\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}}.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}@container field-group (width>=28rem){.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:var(--primary)}.\[\&\>button\]\:hidden>button{display:none}.has-\[\>\[data-align\=block-end\]\]\:\[\&\>input\]\:pt-3:has(>[data-align=block-end])>input{padding-top:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=block-start\]\]\:\[\&\>input\]\:pb-3:has(>[data-align=block-start])>input{padding-bottom:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=inline-end\]\]\:\[\&\>input\]\:pr-1\.5:has(>[data-align=inline-end])>input{padding-right:calc(var(--spacing) * 1.5)}.has-\[\>\[data-align\=inline-start\]\]\:\[\&\>input\]\:pl-1\.5:has(>[data-align=inline-start])>input{padding-left:calc(var(--spacing) * 1.5)}.\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-2\.5\!>svg{width:calc(var(--spacing) * 2.5)!important;height:calc(var(--spacing) * 2.5)!important}.\[\&\>svg\]\:size-4>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.group-has-data-\[size\=lg\]\/avatar-group\:\[\&\>svg\]\:size-5:is(:where(.group\/avatar-group):has([data-size=lg]) *)>svg{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.group-has-data-\[size\=sm\]\/avatar-group\:\[\&\>svg\]\:size-3:is(:where(.group\/avatar-group):has([data-size=sm]) *)>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=default\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=default] *)>svg,.group-data-\[size\=lg\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=lg] *)>svg{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:\[\&\>svg\]\:hidden:is(:where(.group\/avatar)[data-size=sm] *)>svg{display:none}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3>svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5>svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}[data-side=left][data-collapsible=offExamples] .\[\[data-side\=left\]\[data-collapsible\=offExamples\]_\&\]\:-right-2{right:calc(var(--spacing) * -2)}[data-side=left][data-state=collapsed] .\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offExamples] .\[\[data-side\=right\]\[data-collapsible\=offExamples\]_\&\]\:-left-2{left:calc(var(--spacing) * -2)}[data-side=right][data-state=collapsed] .\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}[data-variant=legend]+.\[\[data-variant\=legend\]\+\&\]\:-mt-1\.5{margin-top:calc(var(--spacing) * -1.5)}}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-wght-normal.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-vietnamese-wght-normal.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-wght-normal.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(data:font/woff2;base64,d09GMgABAAAAAAfsABQAAAAAEAwAAAeCAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhwbHhwoP0hWQVJbBmA/U1RBVIFiJyYAdC9qEQgKhGSEAAsgADCGCAE2AiQDOgQgBYlMB4EUDAcbLQ4onoexrSC/2ZyLAa8p8VHB8/x3Vue+V0hVJalMJg2nx/TCrQXxBeqLjQG7FyM1WEa/X1tEXN7cFz9EJEMmMUz3RihWSSKeQCbcIou0izz/C8v+fq3VfajEa9gDD11CImXS7qL/RJFVzC1qiB6KmKeD6TZdQ6IRGv78dL6uSVVCfgni5mzu7kcgQBgAEAQTQRCoL++STTYybkJxNfQxAAIAGu8OdEB9teW2jh4BpgDqFjAeSEByW3zFP0CBBgNMsMCGEDjgggdhiEAUAeIIED7ABTDUEnkIE9Q9ahFgKttcVhApo4ACB4qobHaccgDfEjFO6aaWUhjMLt2SyIvHKoDqoA4CSUwEIYQCEjhAO9R1G6keDeDZGjNo+AhxOjCEGTr1WeIF3kYBiLAOKvkJSMiKX0VdAyQt3SDJClCkxJCHkCzfqyVTriJZLcolS32JZHUekq2TYNkYtCtjYHMQXSxGjXDz2t/yLWXzDzxz+o3zFwDEaN23F+13pyMdQAEaSKAR9vcGq4A4MTSKCElGW+M7UcY7xqkggITb28ZJhlqc9q2twYKTt0NjixBgYvO9BIihEBLYuOFXQzfIQ7dXGUEEEgFDooBfAzqiQbpJrhiWSuKJCRFKYbHCyJKI2G5GiZbNAvgAu5pc3vwx4G+g3aDkhklABiSz0BICXrYghtYhx/cdJ+44rY2oZ0aMNRFz3VZjb6W33F3gzltqtOCV8tTHSpOeXuItfvr5lCdfzFpqtEitvqdcdGGFd28ZqqC0tPbeChGXgrIlnhSWu/eUso4uKWFLugyDzQJhflY4659+WjQ++6x72WUMv9G8mw6QJl7BVxX5fe/kpUsOvnZwee9uQ0cGXYd0o89XB2748sDSnt8d2VphdOTTgceDVvOds0v9P/s7HPq15aGun/6Vllb56f1dl0t1LejqrNkpdRZsG8TOnM5vkBG5oiVyVGnS8LHps5cfNWJs6qKPfaNSxiQNBUm3cKNWROr0GSur7Za31k1vieq7LH11VF+jXdRIasRKflc7jkobm1Z9te1IyZA0pDkhLR98+H37Zf1c/8at+dB7x+7GfVyTfJMPiYztsnl59Y5l4j+0n1RXlpHnF3Tq7HecmNF/CJodEMAikruxiyJaGLvHOdAfoA+oDvpjBm2b91cHGRZMU9n25xEU0A8fgEEAdKI3Q1iDtc034sug5YVMkE2jsE+BIkwSoQ3gxXMqz9tELp48bd0cFKOKS7xYjEuXBnZP5ia7DyiO/X/YI+PQSbt2uSdqAkWL9nQbV1XB94/+uPfdZz8dnXYFBYrcTl2SIR/ybxJNJPz/Gupb0JaZeens2ekC7EKr8t+Ls/P5VJPYJdHKyqfg2nqU6bhlidzcddQV/7MmecTzJ5VPcKXkNKSEogHjYFx6QZ7rQ+FSe8njaiNuOnXS8H2ScQ619c2mC3VTtauL0rRbXd/CkSOP37FY9Zkjz8+GibYUMOEWF+RdrFS8Ecv1SHOpPUPZGEIpjPvFyU5cXKjd6OXqorTqy9GwRd++HVufPGnVsW+aO3vggKZ18jR9sXaTC1PWTEsVUaK0FkNySbTQDqlm2PfDjZcu4aalnSLKjnOoYQ0nUlqqXcGpPu/4VgV/xU2pAqW4BW3qzhQ8/hFKhV2qE3+BKAtDqBXjfgnVdH4y0wg5tbVNRenNdTWOrenWLcupQdmsbq5b+18piTe/xRdp1xbILxNPJGInm2z6hoB21Lal0i+ePTtd7B45+3XhFJ329evskXm7qurUVREotqSluSo/L29d3qDhI4YOQqWhI4YNvBNfsMHeXKemXrxQfKeuPOGRVayA3JtkJKEgbPp+dXUDluddutRYLFoXGXWX6N3WFaGLbQtRSitVYNacTNSdy7AaG/HSaUEANcBoGXNdcZvZsOqQ1icBDv21/gzAoYPHH/WDW0qNR3QTYKEAEHig6o13NXbND06CQPlRtYjGNnSktRc09k1mAMDvAlDKfQjgy6fssInlfzmNAjKkDxoxHOBLdVRAIVt9j4qo+hA1w9T1aNBNTUOTTNUHLbqokE+UAfJXCIGw/IxCSL5GRUJeR40rL/UxTm4Q08H6MbCs70ObuNyIIXrINHQYInF06UUlevTjbQzTh5upiDMzMMogUtEnjPs/Y7jAHCJeB0GBHh04tC6FiB6ZFB1oArUSIoFoqhzCeAN6lHwm0T4C3VVPWvjpSMXReuWesMEcoqrmgtNBGd2noWeV0hNAz9rFeShNJxHGsPa3HXeKTk8b55hahySYHaYKKFFLpCfN8rsoaJn01CR04Gkc+5k7KVTCmClX8Q10HCrUEkVlSX+XO33oQR9609tJ516H497WSobWs5Up6TLaS10/dessIskgJSLiDlWvHVUywpkQ7hdPZqGyiEF0uVQerVcPamT1A3eKXdyI1vG9OoflrSXihZ1qqGE3nhmAgiIbRCQgPLEPtOM3UQwTLYaYYomNlpA44opnjV6jkD6id80OOrzf6BzmMD6eEa1zKyeYG1fzfEf16V6jw9XYOaar1/b2kP/IYX8oR2mcFvv2GtBV3JXgd437AQAA)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-cyrillic-wght-normal.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-greek-wght-normal.woff2)format("woff2-variations");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-vietnamese-wght-normal.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-wght-normal.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--font-sans:"Geist Variable", sans-serif;--font-mono:"JetBrains Mono Variable", monospace;--background:#f4f1ec;--foreground:#1d2930;--card:#f9f8f6;--card-foreground:#1d2930;--popover:#fbfaf9;--popover-foreground:#1d2930;--primary:#305030;--primary-foreground:#f5f3ef;--secondary:#eae5d7;--secondary-foreground:#263740;--muted:#e9e7e2;--muted-foreground:#576b75;--accent:#e7e2da;--accent-foreground:#1d2930;--destructive:#c32222;--destructive-foreground:#fff;--border:#b6c9b6;--input:#dedbd3;--ring:#3e743e;--chart-1:#2e6b2e;--chart-2:#cfa117;--chart-3:#297aa3;--chart-4:#cc8c33;--chart-5:#b82e2e;--radius:.25rem;--sidebar:#f5f3f0;--sidebar-background:#f5f3f0;--sidebar-foreground:#263740;--sidebar-primary:#305030;--sidebar-primary-foreground:#f5f3ef;--sidebar-accent:#e7e2da;--sidebar-accent-foreground:#1d2930;--sidebar-border:#c4d4c4;--sidebar-ring:#3e743e;--tactical-amber:#c2940a;--tactical-amber-dim:#d6b85c;--tactical-green:#267326;--tactical-green-dim:#7ab87a;--tactical-cyan:#24868f;--tactical-cyan-dim:#5eb2ba;--tactical-red:#bd2828;--tactical-olive:#64783a;--scanline-opacity:.015}.dark{--background:#0d1012;--foreground:#e0ddd1;--card:#14181a;--card-foreground:#e0ddd1;--popover:#111517;--popover-foreground:#e0ddd1;--primary:#fa0;--primary-foreground:#0d1012;--secondary:#323922;--secondary-foreground:#c6d2ac;--muted:#202527;--muted-foreground:#819098;--accent:#292f32;--accent-foreground:#ebe8e0;--destructive:#c32222;--destructive-foreground:#fff;--border:#203c32;--input:#292f32;--ring:#ffbf00;--chart-1:#ffbf00;--chart-2:#22c38e;--chart-3:#1aa1e6;--chart-4:#86ac39;--chart-5:#d92626;--sidebar:#111517;--sidebar-background:#111517;--sidebar-foreground:#e0ddd1;--sidebar-primary:#ffbf00;--sidebar-primary-foreground:#0d1012;--sidebar-accent:#292f32;--sidebar-accent-foreground:#ebe8e0;--sidebar-border:#203c32;--sidebar-ring:#ffbf00;--tactical-amber:#ffbf00;--tactical-amber-dim:#a17d12;--tactical-green:#22c38e;--tactical-green-dim:#20604a;--tactical-cyan:#17bfcf;--tactical-cyan-dim:#196066;--tactical-red:#d92626;--tactical-olive:#576b2e;--scanline-opacity:.03}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:#ffe2e2;--color-red-800:#9f0712;--color-red-900:#82181a;--color-amber-400:#fcbb00;--color-amber-500:#f99c00;--color-amber-700:#b75000;--color-yellow-100:#fef9c2;--color-yellow-800:#874b00;--color-yellow-900:#733e0a;--color-green-100:#dcfce7;--color-green-600:#00a544;--color-green-800:#016630;--color-green-900:#0d542b;--color-emerald-400:#00d294;--color-emerald-500:#00bb7f;--color-emerald-700:#007956;--color-blue-500:#3080ff;--color-gray-100:#f3f4f6;--color-gray-800:#1e2939;--color-zinc-100:#f4f4f5;--color-zinc-400:#9f9fa9;--color-zinc-500:#71717b;--color-zinc-800:#27272a;--color-black:#000;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--tracking-widest:.1em;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 2px);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-foreground:var(--foreground)}@supports (color:lab(0% 0 0)){:root,:host{--color-red-100:lab(92.243% 10.2865 3.83865);--color-red-800:lab(33.7174% 55.8993 41.0293);--color-red-900:lab(28.5139% 44.5539 29.0463);--color-amber-400:lab(80.1641% 16.6016 99.2089);--color-amber-500:lab(72.7183% 31.8672 97.9407);--color-amber-700:lab(47.2709% 42.9082 69.2966);--color-yellow-100:lab(97.3564% -4.51407 27.344);--color-yellow-800:lab(38.7484% 23.5833 51.4916);--color-yellow-900:lab(32.3865% 21.1273 38.5959);--color-green-100:lab(96.1861% -13.8464 6.52365);--color-green-600:lab(59.0978% -58.6621 41.2579);--color-green-800:lab(37.4616% -36.7971 22.9692);--color-green-900:lab(30.797% -29.6927 17.382);--color-emerald-400:lab(75.0771% -60.7313 19.4147);--color-emerald-500:lab(66.9756% -58.27 19.5419);--color-emerald-700:lab(44.4871% -41.0396 11.0361);--color-blue-500:lab(54.1736% 13.3369 -74.6839);--color-gray-100:lab(96.1596% -.0823438 -1.13575);--color-gray-800:lab(16.1051% -1.18239 -11.7533);--color-zinc-100:lab(96.1634% .0993311 -.364041);--color-zinc-400:lab(65.6464% 1.53497 -5.42429);--color-zinc-500:lab(47.8878% 1.65477 -5.77283);--color-zinc-800:lab(15.7305% .613764 -2.16959)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}*{scrollbar-width:thin}html,body{overscroll-behavior:none}body{background-color:var(--background);width:100%;min-height:100svh;color:var(--foreground);font-family:var(--font-sans), sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.font-mono{font-family:var(--font-mono), monospace}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.\@container\/field-group{container:field-group/inline-size}.\@container{container-type:inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-3\.5{top:calc(var(--spacing) * 3.5)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:0}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.left-0{left:0}.left-1\/2{left:50%}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.order-first{order:-9999}.order-last{order:9999}.col-span-2{grid-column:span 2/span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-0{margin-inline:0}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3\.5{margin-inline:calc(var(--spacing) * 3.5)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-auto{margin-right:auto}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.ml-1{margin-left:var(--spacing)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.field-sizing-content{field-sizing:content}.aspect-square{aspect-ratio:1}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-\[1\.2rem\]{width:1.2rem;height:1.2rem}.size-full{width:100%;height:100%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-\[calc\(--spacing\(4\.75\)\)\]{height:calc(calc(var(--spacing) * 4.75))}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-svh{height:100svh}.max-h-\(--available-height\){max-height:var(--available-height)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[85vh\]{max-height:85vh}.max-h-\[min\(calc\(--spacing\(72\)---spacing\(9\)\)\,calc\(var\(--available-height\)---spacing\(9\)\)\)\]{max-height:min(calc(calc(var(--spacing) * 72) - calc(var(--spacing) * 9)), calc(var(--available-height) - calc(var(--spacing) * 9)))}.min-h-0{min-height:0}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-svh{min-height:100svh}.w-\(--anchor-width\){width:var(--anchor-width)}.w-\(--sidebar-width\){width:var(--sidebar-width)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\(--available-width\){max-width:var(--available-width)}.max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-\[calc\(var\(--anchor-width\)\+--spacing\(7\)\)\]{min-width:calc(var(--anchor-width) + calc(var(--spacing) * 7))}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.origin-\(--transform-origin\){transform-origin:var(--transform-origin)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[calc\(-50\%-2px\)\]{--tw-translate-y:calc(-50% - 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-none{translate:none}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-3d{scale:var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)}.rotate-0{rotate:0deg}.rotate-45{rotate:45deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:var(--spacing)}.scroll-py-1{scroll-padding-block:var(--spacing)}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}:where(.-space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-\[calc\(var\(--radius-sm\)\+2px\)\]{border-radius:calc(var(--radius-sm) + 2px)}.rounded-\[calc\(var\(--radius-sm\)-2px\)\]{border-radius:calc(var(--radius-sm) - 2px)}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-lg{border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-lg{border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-bs{border-block-start-style:var(--tw-border-style);border-block-start-width:1px}.border-be{border-block-end-style:var(--tw-border-style);border-block-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-border{border-color:var(--border)}.border-destructive{border-color:var(--destructive)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/30{border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/10{border-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/20{border-color:color-mix(in oklab, var(--foreground) 20%, transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-primary{border-color:var(--primary)}.border-sidebar-border{border-color:var(--sidebar-border)}.border-tactical-amber\/40{border-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.border-tactical-amber\/40{border-color:color-mix(in oklab, var(--tactical-amber) 40%, transparent)}}.border-transparent{border-color:#0000}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-background{background-color:var(--background)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-border,.bg-border\/50{background-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.bg-border\/50{background-color:color-mix(in oklab, var(--border) 50%, transparent)}}.bg-card{background-color:var(--card)}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-foreground{background-color:var(--foreground)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-100{background-color:var(--color-green-100)}.bg-input\/20{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.bg-input\/20{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/10{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.bg-muted-foreground\/10{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}.bg-muted\/20{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/20{background-color:color-mix(in oklab, var(--muted) 20%, transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/30{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/50{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-sidebar-border{background-color:var(--sidebar-border)}.bg-tactical-amber\/5{background-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.bg-tactical-amber\/5{background-color:color-mix(in oklab, var(--tactical-amber) 5%, transparent)}}.bg-transparent{background-color:#0000}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-clip-padding{background-clip:padding-box}.bg-repeat{background-repeat:repeat}.mask-no-clip{-webkit-mask-clip:no-clip;mask-clip:no-clip}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.fill-foreground{fill:var(--foreground)}.object-cover{object-fit:cover}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-0{padding-top:0}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:0}.pb-1{padding-bottom:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.text-\[0\.625rem\]{font-size:.625rem}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.text-wrap{text-wrap:wrap}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-700{color:var(--color-amber-700)}.text-background{color:var(--background)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-emerald-700{color:var(--color-emerald-700)}.text-foreground{color:var(--foreground)}.text-gray-800{color:var(--color-gray-800)}.text-green-600{color:var(--color-green-600)}.text-green-800{color:var(--color-green-800)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground,.text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.text-sidebar-foreground\/70{color:color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)}}.text-tactical-amber{color:var(--tactical-amber)}.text-tactical-cyan{color:var(--tactical-cyan)}.text-tactical-green{color:var(--tactical-green)}.text-yellow-800{color:var(--color-yellow-800)}.text-zinc-500{color:var(--color-zinc-500)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.underline-offset-3{text-underline-offset:3px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-50{opacity:.5}.bg-blend-color{background-blend-mode:color}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_0_1px_hsl\(var\(--sidebar-border\)\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-border)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.inset-ring{--tw-inset-ring-shadow:inset 0 0 0 1px var(--tw-inset-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-background{--tw-ring-color:var(--background)}.ring-foreground\/10{--tw-ring-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.ring-foreground\/10{--tw-ring-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}.group-focus-within\/menu-item\:opacity-100:is(:where(.group\/menu-item):focus-within *){opacity:1}@media (hover:hover){.group-hover\/menu-item\:opacity-100:is(:where(.group\/menu-item):hover *){opacity:1}}.group-focus\/dropdown-menu-item\:text-accent-foreground:is(:where(.group\/dropdown-menu-item):focus *){color:var(--accent-foreground)}.group-has-data-\[orientation\=horizontal\]\/field\:text-balance:is(:where(.group\/field):has([data-orientation=horizontal]) *){text-wrap:balance}.group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--spacing) * 8)}.group-has-data-\[size\=lg\]\/avatar-group\:size-10:is(:where(.group\/avatar-group):has([data-size=lg]) *){width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.group-has-data-\[size\=sm\]\/avatar-group\:size-6:is(:where(.group\/avatar-group):has([data-size=sm]) *){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.group-has-data-\[slot\=combobox-clear\]\/input-group\:hidden:is(:where(.group\/input-group):has([data-slot=combobox-clear]) *){display:none}.group-has-\[\>input\]\/input-group\:pt-2:is(:where(.group\/input-group):has(>input) *){padding-top:calc(var(--spacing) * 2)}.group-has-\[\>input\]\/input-group\:pb-2:is(:where(.group\/input-group):has(>input) *){padding-bottom:calc(var(--spacing) * 2)}.group-data-empty\/combobox-content\:flex:is(:where(.group\/combobox-content)[data-empty] *){display:flex}.group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.group)[data-collapsible=icon] *){margin-top:calc(var(--spacing) * -8)}.group-data-\[collapsible\=icon\]\:hidden:is(:where(.group)[data-collapsible=icon] *){display:none}.group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--spacing) * 8)!important;height:calc(var(--spacing) * 8)!important}.group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)))}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)) + 2px)}.group-data-\[collapsible\=icon\]\:\[scrollbar-width\:none\]:is(:where(.group)[data-collapsible=icon] *){scrollbar-width:none}.group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.group)[data-collapsible=icon] *){padding:0!important}.group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing) * 2)!important}.group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.group)[data-collapsible=icon] *){opacity:0}.group-data-\[collapsible\=offExamples\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){right:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){left:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:w-0:is(:where(.group)[data-collapsible=offExamples] *){width:0}.group-data-\[collapsible\=offExamples\]\:translate-x-0:is(:where(.group)[data-collapsible=offExamples] *){--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *),.group-data-\[disabled\=true\]\/field\:opacity-50:is(:where(.group\/field)[data-disabled=true] *),.group-data-\[disabled\=true\]\/input-group\:opacity-50:is(:where(.group\/input-group)[data-disabled=true] *){opacity:.5}.group-data-\[panel-open\]\:rotate-180:is(:where(.group)[data-panel-open] *){rotate:180deg}.group-data-\[side\=left\]\:-right-4:is(:where(.group)[data-side=left] *){right:calc(var(--spacing) * -4)}.group-data-\[side\=left\]\:border-r:is(:where(.group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.group-data-\[side\=right\]\:left-0:is(:where(.group)[data-side=right] *){left:0}.group-data-\[side\=right\]\:rotate-180:is(:where(.group)[data-side=right] *){rotate:180deg}.group-data-\[side\=right\]\:border-l:is(:where(.group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.group-data-\[size\=default\]\/avatar\:size-2\.5:is(:where(.group\/avatar)[data-size=default] *){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.group-data-\[size\=lg\]\/avatar\:size-3:is(:where(.group\/avatar)[data-size=lg] *){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=sm\]\/alert-dialog-content\:grid:is(:where(.group\/alert-dialog-content)[data-size=sm] *){display:grid}.group-data-\[size\=sm\]\/alert-dialog-content\:grid-cols-2:is(:where(.group\/alert-dialog-content)[data-size=sm] *){grid-template-columns:repeat(2,minmax(0,1fr))}.group-data-\[size\=sm\]\/avatar\:size-2:is(:where(.group\/avatar)[data-size=sm] *){width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:text-xs:is(:where(.group\/avatar)[data-size=sm] *){font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.group-data-\[size\=sm\]\/card\:px-3:is(:where(.group\/card)[data-size=sm] *){padding-inline:calc(var(--spacing) * 3)}.group-data-\[variant\=floating\]\:rounded-lg:is(:where(.group)[data-variant=floating] *){border-radius:calc(var(--radius) + 2px)}.group-data-\[variant\=floating\]\:shadow-sm:is(:where(.group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-1:is(:where(.group)[data-variant=floating] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-sidebar-border:is(:where(.group)[data-variant=floating] *){--tw-ring-color:var(--sidebar-border)}.group-data-\[variant\=outline\]\/field-group\:-mb-2:is(:where(.group\/field-group)[data-variant=outline] *){margin-bottom:calc(var(--spacing) * -2)}@media (hover:hover){.peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-data-active\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button)[data-active]~*){color:var(--sidebar-accent-foreground)}.peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.peer\/menu-button)[data-size=default]~*){top:calc(var(--spacing) * 1.5)}.peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.peer\/menu-button)[data-size=lg]~*){top:calc(var(--spacing) * 2.5)}.peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.peer\/menu-button)[data-size=sm]~*){top:var(--spacing)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-6::file-selector-button{height:calc(var(--spacing) * 6)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-xs\/relaxed::file-selector-button{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--spacing) * -2)}.after\:inset-0:after{content:var(--tw-content);inset:0}.after\:inset-y-0:after{content:var(--tw-content);inset-block:0}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.after\:rounded-full:after{content:var(--tw-content);border-radius:2147483647px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-border:after{content:var(--tw-content);border-color:var(--border)}.after\:mix-blend-darken:after{content:var(--tw-content);mix-blend-mode:darken}.group-data-\[collapsible\=offExamples\]\:after\:left-full:is(:where(.group)[data-collapsible=offExamples] *):after{content:var(--tw-content);left:100%}.last\:mt-0:last-child{margin-top:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.autofill\:\[-webkit-text-fill-color\:var\(--color-foreground\)\]:autofill{-webkit-text-fill-color:var(--color-foreground)}.autofill\:\[transition\:background-color_9999s_ease-in-out_0s\]:autofill{transition:background-color 9999s ease-in-out}.focus-within\:border-ring:focus-within{border-color:var(--ring)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}@media (hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/20:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/20:hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.hover\:bg-input\/40:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/40:hover{background-color:color-mix(in oklab, var(--input) 40%, transparent)}}.hover\:bg-input\/50:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/50:hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.hover\:bg-muted:hover{background-color:var(--muted)}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-\[0_0_0_1px_hsl\(var\(--sidebar-accent\)\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-accent)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:group-data-\[collapsible\=offExamples\]\:bg-sidebar:hover:is(:where(.group)[data-collapsible=offExamples] *){background-color:var(--sidebar)}.hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus,:is(.focus\:\*\*\:text-accent-foreground:focus *),:is(.not-data-\[variant\=destructive\]\:focus\:\*\*\:text-accent-foreground:not([data-variant=destructive]):focus *){color:var(--accent-foreground)}.focus-visible\:border-destructive\/40:focus-visible{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:border-destructive\/40:focus-visible{border-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible,.focus-visible\:ring-\[2px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:border-inherit:focus-within{border-color:inherit}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:ring-0:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-disabled\:pointer-events-none:has(:disabled){pointer-events:none}.has-disabled\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-disabled\:opacity-50:has(:disabled){opacity:.5}.has-aria-invalid\:border-destructive:has([aria-invalid=true]){border-color:var(--destructive)}.has-aria-invalid\:ring-2:has([aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.has-data-\[align\=block-end\]\:rounded-md:has([data-align=block-end]),.has-data-\[align\=block-start\]\:rounded-md:has([data-align=block-start]){border-radius:var(--radius)}.has-data-\[icon\=inline-end\]\:pr-1\.5:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-end\]\:pr-2:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 2)}.has-data-\[icon\=inline-start\]\:pl-1\.5:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-start\]\:pl-2:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 2)}.has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_auto_1fr\]:has([data-slot=alert-dialog-media]){grid-template-rows:auto auto 1fr}.has-data-\[slot\=alert-dialog-media\]\:gap-x-4:has([data-slot=alert-dialog-media]){column-gap:calc(var(--spacing) * 4)}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-data-\[slot\=card-description\]\:grid-rows-\[auto_auto\]:has([data-slot=card-description]){grid-template-rows:auto auto}.has-data-\[slot\=combobox-chip\]\:px-1:has([data-slot=combobox-chip]){padding-inline:var(--spacing)}.has-data-\[slot\=combobox-chip-remove\]\:pr-0:has([data-slot=combobox-chip-remove]){padding-right:0}.has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:border-ring:has([data-slot=input-group-control]:focus-visible){border-color:var(--ring)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-2:has([data-slot=input-group-control]:focus-visible){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:border-destructive:has([data-slot][aria-invalid=true]){border-color:var(--destructive)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-2:has([data-slot][aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-\[textarea\]\:rounded-md:has(:is(textarea)){border-radius:var(--radius)}.has-\[\>\[data-align\=block-end\]\]\:h-auto:has(>[data-align=block-end]){height:auto}.has-\[\>\[data-align\=block-end\]\]\:flex-col:has(>[data-align=block-end]){flex-direction:column}.has-\[\>\[data-align\=block-start\]\]\:h-auto:has(>[data-align=block-start]){height:auto}.has-\[\>\[data-align\=block-start\]\]\:flex-col:has(>[data-align=block-start]){flex-direction:column}.has-\[\>\[data-slot\=checkbox-group\]\]\:gap-3:has(>[data-slot=checkbox-group]){gap:calc(var(--spacing) * 3)}.has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}.has-\[\>\[data-slot\=field\]\]\:w-full:has(>[data-slot=field]){width:100%}.has-\[\>\[data-slot\=field\]\]\:flex-col:has(>[data-slot=field]){flex-direction:column}.has-\[\>\[data-slot\=field\]\]\:rounded-md:has(>[data-slot=field]){border-radius:var(--radius)}.has-\[\>\[data-slot\=field\]\]\:border:has(>[data-slot=field]){border-style:var(--tw-border-style);border-width:1px}.has-\[\>\[data-slot\=radio-group\]\]\:gap-3:has(>[data-slot=radio-group]){gap:calc(var(--spacing) * 3)}.has-\[\>button\]\:mr-\[-0\.275rem\]:has(>button){margin-right:-.275rem}.has-\[\>button\]\:ml-\[-0\.275rem\]:has(>button){margin-left:-.275rem}.has-\[\>img\:first-child\]\:pt-0:has(>img:first-child){padding-top:0}.has-\[\>kbd\]\:mr-\[-0\.275rem\]:has(>kbd){margin-right:-.275rem}.has-\[\>kbd\]\:ml-\[-0\.275rem\]:has(>kbd){margin-left:-.275rem}.has-\[\>svg\]\:p-0:has(>svg){padding:0}.has-\[\>textarea\]\:h-auto:has(>textarea){height:auto}.aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.aria-expanded\:bg-muted[aria-expanded=true]{background-color:var(--muted)}.aria-expanded\:bg-secondary[aria-expanded=true]{background-color:var(--secondary)}.aria-expanded\:text-foreground[aria-expanded=true]{color:var(--foreground)}.aria-expanded\:text-secondary-foreground[aria-expanded=true]{color:var(--secondary-foreground)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-0[aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-2[aria-invalid=true],.aria-invalid\:ring-\[2px\][aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-active\:bg-sidebar-accent[data-active]{background-color:var(--sidebar-accent)}.data-active\:font-medium[data-active]{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.data-active\:text-sidebar-accent-foreground[data-active]{color:var(--sidebar-accent-foreground)}.data-closed\:overflow-hidden[data-closed]{overflow:hidden}.data-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.data-disabled\:opacity-50[data-disabled]{opacity:.5}.data-empty\:p-0[data-empty]{padding:0}.data-ending-style\:opacity-0[data-ending-style]{opacity:0}.data-highlighted\:bg-accent[data-highlighted]{background-color:var(--accent)}.data-highlighted\:text-accent-foreground[data-highlighted],:is(.not-data-\[variant\=destructive\]\:data-highlighted\:\*\*\:text-accent-foreground:not([data-variant=destructive])[data-highlighted] *){color:var(--accent-foreground)}.data-inset\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-open\:bg-accent[data-open]{background-color:var(--accent)}.data-open\:text-accent-foreground[data-open]{color:var(--accent-foreground)}.data-open\:opacity-100[data-open]{opacity:1}@media (hover:hover){.data-open\:hover\:bg-sidebar-accent[data-open]:hover{background-color:var(--sidebar-accent)}.data-open\:hover\:text-sidebar-accent-foreground[data-open]:hover{color:var(--sidebar-accent-foreground)}}.data-placeholder\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-pressed\:bg-transparent[data-pressed]{background-color:#0000}.data-selected\:border-primary[data-selected]{border-color:var(--primary)}.data-selected\:text-foreground[data-selected]{color:var(--foreground)}.data-starting-style\:opacity-0[data-starting-style]{opacity:0}.data-\[chips\=true\]\:min-w-\(--anchor-width\)[data-chips=true]{min-width:var(--anchor-width)}.data-\[invalid\=true\]\:text-destructive[data-invalid=true]{color:var(--destructive)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[orientation\=vertical\]\:self-stretch[data-orientation=vertical]{align-self:stretch}.data-\[side\=bottom\]\:inset-x-0[data-side=bottom]{inset-inline:0}.data-\[side\=bottom\]\:top-1[data-side=bottom]{top:var(--spacing)}.data-\[side\=bottom\]\:bottom-0[data-side=bottom]{bottom:0}.data-\[side\=bottom\]\:h-auto[data-side=bottom]{height:auto}.data-\[side\=bottom\]\:border-t[data-side=bottom]{border-top-style:var(--tw-border-style);border-top-width:1px}.data-\[side\=left\]\:inset-y-0[data-side=left]{inset-block:0}.data-\[side\=left\]\:top-1\/2\![data-side=left]{top:50%!important}.data-\[side\=left\]\:-right-1[data-side=left]{right:calc(var(--spacing) * -1)}.data-\[side\=left\]\:left-0[data-side=left]{left:0}.data-\[side\=left\]\:h-full[data-side=left]{height:100%}.data-\[side\=left\]\:w-3\/4[data-side=left]{width:75%}.data-\[side\=left\]\:-translate-y-1\/2[data-side=left]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:border-r[data-side=left]{border-right-style:var(--tw-border-style);border-right-width:1px}.data-\[side\=right\]\:inset-y-0[data-side=right]{inset-block:0}.data-\[side\=right\]\:top-1\/2\![data-side=right]{top:50%!important}.data-\[side\=right\]\:right-0[data-side=right]{right:0}.data-\[side\=right\]\:-left-1[data-side=right]{left:calc(var(--spacing) * -1)}.data-\[side\=right\]\:h-full[data-side=right]{height:100%}.data-\[side\=right\]\:w-3\/4[data-side=right]{width:75%}.data-\[side\=right\]\:-translate-y-1\/2[data-side=right]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:border-l[data-side=right]{border-left-style:var(--tw-border-style);border-left-width:1px}.data-\[side\=top\]\:inset-x-0[data-side=top]{inset-inline:0}.data-\[side\=top\]\:top-0[data-side=top]{top:0}.data-\[side\=top\]\:-bottom-2\.5[data-side=top]{bottom:calc(var(--spacing) * -2.5)}.data-\[side\=top\]\:h-auto[data-side=top]{height:auto}.data-\[side\=top\]\:border-b[data-side=top]{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.data-\[size\=default\]\:h-7[data-size=default]{height:calc(var(--spacing) * 7)}.data-\[size\=default\]\:max-w-xs[data-size=default]{max-width:var(--container-xs)}.data-\[size\=lg\]\:size-10[data-size=lg]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.data-\[size\=md\]\:text-xs[data-size=md]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.data-\[size\=sm\]\:size-6[data-size=sm]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:h-6[data-size=sm]{height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:max-w-64[data-size=sm]{max-width:calc(var(--spacing) * 64)}.data-\[size\=sm\]\:gap-3[data-size=sm]{gap:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:py-3[data-size=sm]{padding-block:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:text-xs[data-size=sm]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}:is(.\*\:data-\[slot\=avatar\]\:ring-2>*)[data-slot=avatar]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\:data-\[slot\=avatar\]\:ring-background>*)[data-slot=avatar]{--tw-ring-color:var(--background)}.data-\[slot\=checkbox-group\]\:gap-3[data-slot=checkbox-group]{gap:calc(var(--spacing) * 3)}:is(.\*\:data-\[slot\=field\]\:p-2>*)[data-slot=field]{padding:calc(var(--spacing) * 2)}:is(.\*\:data-\[slot\=field-group\]\:gap-4>*)[data-slot=field-group]{gap:calc(var(--spacing) * 4)}:is(.\*\:data-\[slot\=input-group\]\:m-1>*)[data-slot=input-group]{margin:var(--spacing)}:is(.\*\:data-\[slot\=input-group\]\:mb-0>*)[data-slot=input-group]{margin-bottom:0}:is(.\*\:data-\[slot\=input-group\]\:h-7>*)[data-slot=input-group]{height:calc(var(--spacing) * 7)}:is(.\*\:data-\[slot\=input-group\]\:border-none>*)[data-slot=input-group]{--tw-border-style:none;border-style:none}:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}:is(.\*\:data-\[slot\=input-group\]\:shadow-none>*)[data-slot=input-group]{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-\[calc\(var\(--radius-sm\)-2px\)\] *)[data-slot=kbd]{border-radius:calc(var(--radius-sm) - 2px)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-md *)[data-slot=kbd]{border-radius:var(--radius)}:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}:is(.\*\*\:data-\[slot\=kbd\]\:px-1 *)[data-slot=kbd]{padding-inline:var(--spacing)}:is(.\*\*\:data-\[slot\=kbd\]\:text-\[0\.625rem\] *)[data-slot=kbd]{font-size:.625rem}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-1\.5>*)[data-slot=select-value]{gap:calc(var(--spacing) * 1.5)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}.data-\[variant\=label\]\:text-xs\/relaxed[data-variant=label]{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.data-\[variant\=legend\]\:text-sm[data-variant=legend]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.nth-last-2\:-mt-1:nth-last-child(2){margin-top:calc(var(--spacing) * -1)}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-backdrop-filter\:backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}}@media (width>=40rem){.sm\:mr-auto{margin-right:auto}.sm\:flex{display:flex}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[1fr_1fr_1fr_auto\]{grid-template-columns:1fr 1fr 1fr auto}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:self-center{align-self:center}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:row-span-2:is(:where(.group\/alert-dialog-content)[data-size=default] *){grid-row:span 2/span 2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:place-items-start:is(:where(.group\/alert-dialog-content)[data-size=default] *){place-items:start}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:text-left:is(:where(.group\/alert-dialog-content)[data-size=default] *){text-align:left}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:group-has-data-\[slot\=alert-dialog-media\]\/alert-dialog-content\:col-start-2:is(:where(.group\/alert-dialog-content)[data-size=default] *):is(:where(.group\/alert-dialog-content):has([data-slot=alert-dialog-media]) *){grid-column-start:2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_1fr\]:is(:where(.group\/alert-dialog-content)[data-size=default] *):has([data-slot=alert-dialog-media]){grid-template-rows:auto 1fr}.data-\[side\=left\]\:sm\:max-w-sm[data-side=left],.data-\[side\=right\]\:sm\:max-w-sm[data-side=right],.data-\[size\=default\]\:sm\:max-w-sm[data-size=default]{max-width:var(--container-sm)}}@media (width>=48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.md\:text-pretty{text-wrap:pretty}.md\:opacity-0{opacity:0}.md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.peer)[data-variant=inset]~*){margin:calc(var(--spacing) * 2)}.md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.peer)[data-variant=inset]~*){margin-left:0}.md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.peer)[data-variant=inset]~*):is(:where(.peer)[data-state=collapsed]~*){margin-left:calc(var(--spacing) * 2)}.md\:after\:hidden:after{content:var(--tw-content);display:none}}@media (width>=64rem){.lg\:block{display:block}}@media (width>=80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@container field-group (width>=28rem){.\@md\/field-group\:flex-row{flex-direction:row}.\@md\/field-group\:items-center{align-items:center}.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}}.dark\:scale-0:where(.dark,.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:scale-100:where(.dark,.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:-rotate-90:where(.dark,.dark *){rotate:-90deg}.dark\:rotate-0:where(.dark,.dark *){rotate:0deg}.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-green-900:where(.dark,.dark *){background-color:var(--color-green-900)}.dark\:bg-input\/30:where(.dark,.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-input\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.dark\:bg-muted\/30:where(.dark,.dark *){background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-muted\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.dark\:bg-popover:where(.dark,.dark *){background-color:var(--popover)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-yellow-900:where(.dark,.dark *){background-color:var(--color-yellow-900)}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:invert:where(.dark,.dark *){--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.dark\:after\:mix-blend-lighten:where(.dark,.dark *):after{content:var(--tw-content);mix-blend-mode:lighten}@media (hover:hover){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--destructive) 30%, transparent)}}.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}}.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-2\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.group-data-\[collapsible\=icon\]\:\[\&\:\:-webkit-scrollbar\]\:hidden:is(:where(.group)[data-collapsible=icon] *)::-webkit-scrollbar{display:none}.\[\.border-b\]\:pb-2.border-b{padding-bottom:calc(var(--spacing) * 2)}.\[\.border-b\]\:pb-4.border-b{padding-bottom:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-b\]\:pb-3:is(:where(.group\/card)[data-size=sm] *).border-b{padding-bottom:calc(var(--spacing) * 3)}.\[\.border-t\]\:pt-2.border-t{padding-top:calc(var(--spacing) * 2)}.\[\.border-t\]\:pt-4.border-t{padding-top:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-t\]\:pt-3:is(:where(.group\/card)[data-size=sm] *).border-t{padding-top:calc(var(--spacing) * 3)}:is(.\*\:\[a\]\:underline>*):is(a){text-decoration-line:underline}:is(.\*\:\[a\]\:underline-offset-3>*):is(a){text-underline-offset:3px}@media (hover:hover){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[a\]\:hover\:bg-muted:is(a):hover{background-color:var(--muted)}.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.\[a\]\:hover\:text-muted-foreground:is(a):hover{color:var(--muted-foreground)}:is(.\*\:\[a\]\:hover\:text-foreground>*):is(a):hover{color:var(--foreground)}}:is(.\*\:\[img\:first-child\]\:rounded-t-lg>*):is(img:first-child){border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}:is(.\*\:\[img\:last-child\]\:rounded-b-lg>*):is(img:last-child){border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)}:is(.\*\:\[svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4>*):is(svg:not([class*=size-])){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>\*\]\:w-full>*{width:100%}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\*\]\:w-auto>*{width:auto}}.\[\&\>\.sr-only\]\:w-auto>.sr-only{width:auto}.\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}}.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}@container field-group (width>=28rem){.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:var(--primary)}.\[\&\>button\]\:hidden>button{display:none}.has-\[\>\[data-align\=block-end\]\]\:\[\&\>input\]\:pt-3:has(>[data-align=block-end])>input{padding-top:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=block-start\]\]\:\[\&\>input\]\:pb-3:has(>[data-align=block-start])>input{padding-bottom:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=inline-end\]\]\:\[\&\>input\]\:pr-1\.5:has(>[data-align=inline-end])>input{padding-right:calc(var(--spacing) * 1.5)}.has-\[\>\[data-align\=inline-start\]\]\:\[\&\>input\]\:pl-1\.5:has(>[data-align=inline-start])>input{padding-left:calc(var(--spacing) * 1.5)}.\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-2\.5\!>svg{width:calc(var(--spacing) * 2.5)!important;height:calc(var(--spacing) * 2.5)!important}.\[\&\>svg\]\:size-4>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.group-has-data-\[size\=lg\]\/avatar-group\:\[\&\>svg\]\:size-5:is(:where(.group\/avatar-group):has([data-size=lg]) *)>svg{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.group-has-data-\[size\=sm\]\/avatar-group\:\[\&\>svg\]\:size-3:is(:where(.group\/avatar-group):has([data-size=sm]) *)>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=default\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=default] *)>svg,.group-data-\[size\=lg\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=lg] *)>svg{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:\[\&\>svg\]\:hidden:is(:where(.group\/avatar)[data-size=sm] *)>svg{display:none}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3>svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5>svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}[data-side=left][data-collapsible=offExamples] .\[\[data-side\=left\]\[data-collapsible\=offExamples\]_\&\]\:-right-2{right:calc(var(--spacing) * -2)}[data-side=left][data-state=collapsed] .\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offExamples] .\[\[data-side\=right\]\[data-collapsible\=offExamples\]_\&\]\:-left-2{left:calc(var(--spacing) * -2)}[data-side=right][data-state=collapsed] .\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}[data-variant=legend]+.\[\[data-variant\=legend\]\+\&\]\:-mt-1\.5{margin-top:calc(var(--spacing) * -1.5)}}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-wght-normal.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-vietnamese-wght-normal.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-wght-normal.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(data:font/woff2;base64,d09GMgABAAAAAAfsABQAAAAAEAwAAAeCAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhwbHhwoP0hWQVJbBmA/U1RBVIFiJyYAdC9qEQgKhGSEAAsgADCGCAE2AiQDOgQgBYlMB4EUDAcbLQ4onoexrSC/2ZyLAa8p8VHB8/x3Vue+V0hVJalMJg2nx/TCrQXxBeqLjQG7FyM1WEa/X1tEXN7cFz9EJEMmMUz3RihWSSKeQCbcIou0izz/C8v+fq3VfajEa9gDD11CImXS7qL/RJFVzC1qiB6KmKeD6TZdQ6IRGv78dL6uSVVCfgni5mzu7kcgQBgAEAQTQRCoL++STTYybkJxNfQxAAIAGu8OdEB9teW2jh4BpgDqFjAeSEByW3zFP0CBBgNMsMCGEDjgggdhiEAUAeIIED7ABTDUEnkIE9Q9ahFgKttcVhApo4ACB4qobHaccgDfEjFO6aaWUhjMLt2SyIvHKoDqoA4CSUwEIYQCEjhAO9R1G6keDeDZGjNo+AhxOjCEGTr1WeIF3kYBiLAOKvkJSMiKX0VdAyQt3SDJClCkxJCHkCzfqyVTriJZLcolS32JZHUekq2TYNkYtCtjYHMQXSxGjXDz2t/yLWXzDzxz+o3zFwDEaN23F+13pyMdQAEaSKAR9vcGq4A4MTSKCElGW+M7UcY7xqkggITb28ZJhlqc9q2twYKTt0NjixBgYvO9BIihEBLYuOFXQzfIQ7dXGUEEEgFDooBfAzqiQbpJrhiWSuKJCRFKYbHCyJKI2G5GiZbNAvgAu5pc3vwx4G+g3aDkhklABiSz0BICXrYghtYhx/cdJ+44rY2oZ0aMNRFz3VZjb6W33F3gzltqtOCV8tTHSpOeXuItfvr5lCdfzFpqtEitvqdcdGGFd28ZqqC0tPbeChGXgrIlnhSWu/eUso4uKWFLugyDzQJhflY4659+WjQ++6x72WUMv9G8mw6QJl7BVxX5fe/kpUsOvnZwee9uQ0cGXYd0o89XB2748sDSnt8d2VphdOTTgceDVvOds0v9P/s7HPq15aGun/6Vllb56f1dl0t1LejqrNkpdRZsG8TOnM5vkBG5oiVyVGnS8LHps5cfNWJs6qKPfaNSxiQNBUm3cKNWROr0GSur7Za31k1vieq7LH11VF+jXdRIasRKflc7jkobm1Z9te1IyZA0pDkhLR98+H37Zf1c/8at+dB7x+7GfVyTfJMPiYztsnl59Y5l4j+0n1RXlpHnF3Tq7HecmNF/CJodEMAikruxiyJaGLvHOdAfoA+oDvpjBm2b91cHGRZMU9n25xEU0A8fgEEAdKI3Q1iDtc034sug5YVMkE2jsE+BIkwSoQ3gxXMqz9tELp48bd0cFKOKS7xYjEuXBnZP5ia7DyiO/X/YI+PQSbt2uSdqAkWL9nQbV1XB94/+uPfdZz8dnXYFBYrcTl2SIR/ybxJNJPz/Gupb0JaZeens2ekC7EKr8t+Ls/P5VJPYJdHKyqfg2nqU6bhlidzcddQV/7MmecTzJ5VPcKXkNKSEogHjYFx6QZ7rQ+FSe8njaiNuOnXS8H2ScQ619c2mC3VTtauL0rRbXd/CkSOP37FY9Zkjz8+GibYUMOEWF+RdrFS8Ecv1SHOpPUPZGEIpjPvFyU5cXKjd6OXqorTqy9GwRd++HVufPGnVsW+aO3vggKZ18jR9sXaTC1PWTEsVUaK0FkNySbTQDqlm2PfDjZcu4aalnSLKjnOoYQ0nUlqqXcGpPu/4VgV/xU2pAqW4BW3qzhQ8/hFKhV2qE3+BKAtDqBXjfgnVdH4y0wg5tbVNRenNdTWOrenWLcupQdmsbq5b+18piTe/xRdp1xbILxNPJGInm2z6hoB21Lal0i+ePTtd7B45+3XhFJ329evskXm7qurUVREotqSluSo/L29d3qDhI4YOQqWhI4YNvBNfsMHeXKemXrxQfKeuPOGRVayA3JtkJKEgbPp+dXUDluddutRYLFoXGXWX6N3WFaGLbQtRSitVYNacTNSdy7AaG/HSaUEANcBoGXNdcZvZsOqQ1icBDv21/gzAoYPHH/WDW0qNR3QTYKEAEHig6o13NXbND06CQPlRtYjGNnSktRc09k1mAMDvAlDKfQjgy6fssInlfzmNAjKkDxoxHOBLdVRAIVt9j4qo+hA1w9T1aNBNTUOTTNUHLbqokE+UAfJXCIGw/IxCSL5GRUJeR40rL/UxTm4Q08H6MbCs70ObuNyIIXrINHQYInF06UUlevTjbQzTh5upiDMzMMogUtEnjPs/Y7jAHCJeB0GBHh04tC6FiB6ZFB1oArUSIoFoqhzCeAN6lHwm0T4C3VVPWvjpSMXReuWesMEcoqrmgtNBGd2noWeV0hNAz9rFeShNJxHGsPa3HXeKTk8b55hahySYHaYKKFFLpCfN8rsoaJn01CR04Gkc+5k7KVTCmClX8Q10HCrUEkVlSX+XO33oQR9609tJ516H497WSobWs5Up6TLaS10/dessIskgJSLiDlWvHVUywpkQ7hdPZqGyiEF0uVQerVcPamT1A3eKXdyI1vG9OoflrSXihZ1qqGE3nhmAgiIbRCQgPLEPtOM3UQwTLYaYYomNlpA44opnjV6jkD6id80OOrzf6BzmMD6eEa1zKyeYG1fzfEf16V6jw9XYOaar1/b2kP/IYX8oR2mcFvv2GtBV3JXgd437AQAA)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-cyrillic-wght-normal.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-greek-wght-normal.woff2)format("woff2-variations");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-vietnamese-wght-normal.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-wght-normal.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--font-sans:"Geist Variable", sans-serif;--font-mono:"JetBrains Mono Variable", monospace;--background:#f4f1ec;--foreground:#1d2930;--card:#f9f8f6;--card-foreground:#1d2930;--popover:#fbfaf9;--popover-foreground:#1d2930;--primary:#305030;--primary-foreground:#f5f3ef;--secondary:#eae5d7;--secondary-foreground:#263740;--muted:#e9e7e2;--muted-foreground:#576b75;--accent:#e7e2da;--accent-foreground:#1d2930;--destructive:#c32222;--destructive-foreground:#fff;--border:#b6c9b6;--input:#dedbd3;--ring:#3e743e;--chart-1:#2e6b2e;--chart-2:#cfa117;--chart-3:#297aa3;--chart-4:#cc8c33;--chart-5:#b82e2e;--radius:.25rem;--sidebar:#f5f3f0;--sidebar-background:#f5f3f0;--sidebar-foreground:#263740;--sidebar-primary:#305030;--sidebar-primary-foreground:#f5f3ef;--sidebar-accent:#e7e2da;--sidebar-accent-foreground:#1d2930;--sidebar-border:#c4d4c4;--sidebar-ring:#3e743e;--tactical-amber:#c2940a;--tactical-amber-dim:#d6b85c;--tactical-green:#267326;--tactical-green-dim:#7ab87a;--tactical-cyan:#24868f;--tactical-cyan-dim:#5eb2ba;--tactical-red:#bd2828;--tactical-olive:#64783a;--scanline-opacity:.015}.dark{--background:#0d1012;--foreground:#e0ddd1;--card:#14181a;--card-foreground:#e0ddd1;--popover:#111517;--popover-foreground:#e0ddd1;--primary:#fa0;--primary-foreground:#0d1012;--secondary:#323922;--secondary-foreground:#c6d2ac;--muted:#202527;--muted-foreground:#819098;--accent:#292f32;--accent-foreground:#ebe8e0;--destructive:#c32222;--destructive-foreground:#fff;--border:#203c32;--input:#292f32;--ring:#ffbf00;--chart-1:#ffbf00;--chart-2:#22c38e;--chart-3:#1aa1e6;--chart-4:#86ac39;--chart-5:#d92626;--sidebar:#111517;--sidebar-background:#111517;--sidebar-foreground:#e0ddd1;--sidebar-primary:#ffbf00;--sidebar-primary-foreground:#0d1012;--sidebar-accent:#292f32;--sidebar-accent-foreground:#ebe8e0;--sidebar-border:#203c32;--sidebar-ring:#ffbf00;--tactical-amber:#ffbf00;--tactical-amber-dim:#a17d12;--tactical-green:#22c38e;--tactical-green-dim:#20604a;--tactical-cyan:#17bfcf;--tactical-cyan-dim:#196066;--tactical-red:#d92626;--tactical-olive:#576b2e;--scanline-opacity:.03}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/spinifex/services/spinifexui/frontend/dist/assets/rds2.js b/spinifex/services/spinifexui/frontend/dist/assets/rds2.js index 60c966568..bdf333db0 100644 --- a/spinifex/services/spinifexui/frontend/dist/assets/rds2.js +++ b/spinifex/services/spinifexui/frontend/dist/assets/rds2.js @@ -1 +1 @@ -import{t as e}from"./compiler-runtime.js";import{n as t}from"./QueryClientProvider.js";import{t as n}from"./useMutation.js";import{B as r,H as i,I as ee,L as a,R as o,U as s,V as c,W as l,at as u,it as d,l as f,nt as p,ot as m,rt as h,st as g,tt as _,z as v}from"./awsClient.js";import{D as y,E as b,T as x}from"./rds.js";var S=class extends y(x,b,`AddTagsToResource`,ee){},C=class extends y(x,b,`CreateDBInstance`,a){},w=class extends y(x,b,`CreateDBParameterGroup`,o){},T=class extends y(x,b,`CreateDBSnapshot`,v){},E=class extends y(x,b,`CreateDBSubnetGroup`,r){},D=class extends y(x,b,`DeleteDBInstance`,c){},O=class extends y(x,b,`DeleteDBParameterGroup`,i){},k=class extends y(x,b,`DeleteDBSnapshot`,s){},A=class extends y(x,b,`DeleteDBSubnetGroup`,l){},j=class extends y(x,b,`ModifyDBInstance`,_){},M=class extends y(x,b,`ModifyDBParameterGroup`,p){},N=class extends y(x,b,`RebootDBInstance`,h){},P=class extends y(x,b,`RemoveTagsFromResource`,d){},F=class extends y(x,b,`RestoreDBInstanceFromDBSnapshot`,u){},I=class extends y(x,b,`StartDBInstance`,m){},L=class extends y(x,b,`StopDBInstance`,g){},R=e(),z=[`rds`,`dbInstances`],B=[`rds`,`dbSnapshots`],V=[`rds`,`subnetGroups`],H=[`rds`,`parameterGroups`];function U(e){return e.length>0?e:void 0}function W(e){let t=e.filter(e=>e.key.length>0).map(e=>({Key:e.key,Value:e.value}));return t.length>0?t:void 0}function G(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:K,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function K(e){let t=new C({DBInstanceIdentifier:e.dbInstanceIdentifier,Engine:e.engine,EngineVersion:U(e.engineVersion),DBInstanceClass:e.dbInstanceClass,AllocatedStorage:e.allocatedStorage,MasterUsername:e.masterUsername,MasterUserPassword:e.masterUserPassword,DBName:U(e.dbName),Port:e.port===``?void 0:Number(e.port),DBSubnetGroupName:U(e.dbSubnetGroupName),VpcSecurityGroupIds:e.vpcSecurityGroupIds.length>0?e.vpcSecurityGroupIds:void 0,DBParameterGroupName:U(e.dbParameterGroupName),DeletionProtection:e.deletionProtection,BackupRetentionPeriod:e.backupRetentionPeriod,PreferredBackupWindow:U(e.preferredBackupWindow),PreferredMaintenanceWindow:U(e.preferredMaintenanceWindow),Tags:W(e.tags)});return await f().send(t)}function q(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:J,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function J(e){let t=new j({DBInstanceIdentifier:e.dbInstanceIdentifier,DBInstanceClass:e.dbInstanceClass,AllocatedStorage:e.allocatedStorage,DBParameterGroupName:U(e.dbParameterGroupName),VpcSecurityGroupIds:e.vpcSecurityGroupIds,DeletionProtection:e.deletionProtection,BackupRetentionPeriod:e.backupRetentionPeriod,PreferredBackupWindow:U(e.preferredBackupWindow),PreferredMaintenanceWindow:U(e.preferredMaintenanceWindow),MasterUserPassword:U(e.masterUserPassword),ApplyImmediately:e.applyImmediately});return await f().send(t)}function Y(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:X,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function X(e){let t=new D({DBInstanceIdentifier:e.dbInstanceIdentifier,SkipFinalSnapshot:e.skipFinalSnapshot,FinalDBSnapshotIdentifier:e.skipFinalSnapshot?void 0:e.finalSnapshotIdentifier});return await f().send(t)}function Z(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:Q,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function Q(e){let t=new I({DBInstanceIdentifier:e});return await f().send(t)}function $(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:te,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function te(e){let t=new L({DBInstanceIdentifier:e});return await f().send(t)}function ne(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:re,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function re(e){let t=new N({DBInstanceIdentifier:e});return await f().send(t)}function ie(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:ae,onSuccess:()=>{r.invalidateQueries({queryKey:B}),r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function ae(e){let t=new T({DBSnapshotIdentifier:e.dbSnapshotIdentifier,DBInstanceIdentifier:e.dbInstanceIdentifier,Tags:W(e.tags)});return await f().send(t)}function oe(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:se,onSuccess:()=>{r.invalidateQueries({queryKey:B})}},e[0]=r,e[1]=i),n(i)}async function se(e){let t=new k({DBSnapshotIdentifier:e});return await f().send(t)}function ce(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:le,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function le(e){let t=new F({DBInstanceIdentifier:e.dbInstanceIdentifier,DBSnapshotIdentifier:e.dbSnapshotIdentifier,DBInstanceClass:e.dbInstanceClass,AllocatedStorage:e.allocatedStorage,Port:e.port===``?void 0:Number(e.port),DBSubnetGroupName:U(e.dbSubnetGroupName),VpcSecurityGroupIds:e.vpcSecurityGroupIds.length>0?e.vpcSecurityGroupIds:void 0,DBParameterGroupName:U(e.dbParameterGroupName),DeletionProtection:e.deletionProtection,Tags:W(e.tags)});return await f().send(t)}function ue(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:de,onSuccess:()=>{r.invalidateQueries({queryKey:V})}},e[0]=r,e[1]=i),n(i)}async function de(e){let t=new E({DBSubnetGroupName:e.dbSubnetGroupName,DBSubnetGroupDescription:e.dbSubnetGroupDescription,SubnetIds:e.subnetIds,Tags:W(e.tags)});return await f().send(t)}function fe(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:pe,onSuccess:()=>{r.invalidateQueries({queryKey:V})}},e[0]=r,e[1]=i),n(i)}async function pe(e){let t=new A({DBSubnetGroupName:e});return await f().send(t)}function me(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:he,onSuccess:()=>{r.invalidateQueries({queryKey:H})}},e[0]=r,e[1]=i),n(i)}async function he(e){let t=new w({DBParameterGroupName:e.dbParameterGroupName,DBParameterGroupFamily:e.dbParameterGroupFamily,Description:e.description,Tags:W(e.tags)});return await f().send(t)}function ge(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:_e,onSuccess:(e,t)=>{r.invalidateQueries({queryKey:[`rds`,`parameters`,t.dbParameterGroupName]}),r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function _e(e){let t=new M({DBParameterGroupName:e.dbParameterGroupName,Parameters:e.parameters.map(ve)});return await f().send(t)}function ve(e){return{ParameterName:e.name,ParameterValue:e.value,ApplyMethod:e.applyMethod}}function ye(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:be,onSuccess:()=>{r.invalidateQueries({queryKey:H})}},e[0]=r,e[1]=i),n(i)}async function be(e){let t=new O({DBParameterGroupName:e});return await f().send(t)}function xe(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:Se,onSuccess:()=>{r.invalidateQueries({queryKey:[`rds`,`tags`]})}},e[0]=r,e[1]=i),n(i)}async function Se(e){let t=new Set(e.tags.map(we)),n=e.initialKeys.filter(e=>!t.has(e)),r=f();e.tags.length>0&&await r.send(new S({ResourceName:e.resourceName,Tags:e.tags.map(Ce)})),n.length>0&&await r.send(new P({ResourceName:e.resourceName,TagKeys:n}))}function Ce(e){return{Key:e.key,Value:e.value}}function we(e){return e.key}export{Y as a,fe as c,ne as d,ce as f,xe as h,ue as i,q as l,$ as m,me as n,ye as o,Z as p,ie as r,oe as s,G as t,ge as u}; \ No newline at end of file +import{t as e}from"./compiler-runtime.js";import{n as t}from"./QueryClientProvider.js";import{t as n}from"./useMutation.js";import{B as r,H as i,I as a,L as o,R as s,U as c,V as l,W as u,at as d,it as f,l as p,nt as ee,ot as m,rt as h,st as g,tt as _,z as v}from"./awsClient.js";import{D as y,E as b,T as x}from"./rds.js";var S=class extends y(x,b,`AddTagsToResource`,a){},C=class extends y(x,b,`CreateDBInstance`,o){},w=class extends y(x,b,`CreateDBParameterGroup`,s){},T=class extends y(x,b,`CreateDBSnapshot`,v){},E=class extends y(x,b,`CreateDBSubnetGroup`,r){},D=class extends y(x,b,`DeleteDBInstance`,l){},O=class extends y(x,b,`DeleteDBParameterGroup`,i){},k=class extends y(x,b,`DeleteDBSnapshot`,c){},A=class extends y(x,b,`DeleteDBSubnetGroup`,u){},j=class extends y(x,b,`ModifyDBInstance`,_){},M=class extends y(x,b,`ModifyDBParameterGroup`,ee){},N=class extends y(x,b,`RebootDBInstance`,h){},P=class extends y(x,b,`RemoveTagsFromResource`,f){},F=class extends y(x,b,`RestoreDBInstanceFromDBSnapshot`,d){},I=class extends y(x,b,`StartDBInstance`,m){},L=class extends y(x,b,`StopDBInstance`,g){},R=e(),z=[`rds`,`dbInstances`],B=[`rds`,`dbSnapshots`],V=[`rds`,`automatedBackups`],H=[`rds`,`subnetGroups`],U=[`rds`,`parameterGroups`];function W(e){return e.length>0?e:void 0}function G(e){let t=e.filter(e=>e.key.length>0).map(e=>({Key:e.key,Value:e.value}));return t.length>0?t:void 0}function K(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:q,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function q(e){let t=new C({DBInstanceIdentifier:e.dbInstanceIdentifier,Engine:e.engine,EngineVersion:W(e.engineVersion),DBInstanceClass:e.dbInstanceClass,AllocatedStorage:e.allocatedStorage,MasterUsername:e.masterUsername,MasterUserPassword:e.masterUserPassword,DBName:W(e.dbName),Port:e.port===``?void 0:Number(e.port),DBSubnetGroupName:W(e.dbSubnetGroupName),VpcSecurityGroupIds:e.vpcSecurityGroupIds.length>0?e.vpcSecurityGroupIds:void 0,DBParameterGroupName:W(e.dbParameterGroupName),DeletionProtection:e.deletionProtection,BackupRetentionPeriod:e.backupRetentionPeriod,PreferredBackupWindow:W(e.preferredBackupWindow),PreferredMaintenanceWindow:W(e.preferredMaintenanceWindow),Tags:G(e.tags)});return await p().send(t)}function J(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:Y,onSuccess:(e,t)=>{r.invalidateQueries({queryKey:z}),r.invalidateQueries({queryKey:[...V,t.dbInstanceIdentifier]}),r.invalidateQueries({queryKey:B})}},e[0]=r,e[1]=i),n(i)}async function Y(e){let t=new j({DBInstanceIdentifier:e.dbInstanceIdentifier,DBInstanceClass:e.dbInstanceClass,AllocatedStorage:e.allocatedStorage,DBParameterGroupName:W(e.dbParameterGroupName),VpcSecurityGroupIds:e.vpcSecurityGroupIds,DeletionProtection:e.deletionProtection,BackupRetentionPeriod:e.backupRetentionPeriod,PreferredBackupWindow:W(e.preferredBackupWindow),PreferredMaintenanceWindow:W(e.preferredMaintenanceWindow),MasterUserPassword:W(e.masterUserPassword),ApplyImmediately:e.applyImmediately});return await p().send(t)}function X(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:Z,onSuccess:(e,t)=>{r.invalidateQueries({queryKey:z}),r.invalidateQueries({queryKey:[...V,t.dbInstanceIdentifier]}),r.invalidateQueries({queryKey:B})}},e[0]=r,e[1]=i),n(i)}async function Z(e){let t=new D({DBInstanceIdentifier:e.dbInstanceIdentifier,SkipFinalSnapshot:e.skipFinalSnapshot,FinalDBSnapshotIdentifier:e.skipFinalSnapshot?void 0:e.finalSnapshotIdentifier});return await p().send(t)}function Q(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:$,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function $(e){let t=new I({DBInstanceIdentifier:e});return await p().send(t)}function te(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:ne,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function ne(e){let t=new L({DBInstanceIdentifier:e});return await p().send(t)}function re(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:ie,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function ie(e){let t=new N({DBInstanceIdentifier:e});return await p().send(t)}function ae(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:oe,onSuccess:()=>{r.invalidateQueries({queryKey:B}),r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function oe(e){let t=new T({DBSnapshotIdentifier:e.dbSnapshotIdentifier,DBInstanceIdentifier:e.dbInstanceIdentifier,Tags:G(e.tags)});return await p().send(t)}function se(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:ce,onSuccess:()=>{r.invalidateQueries({queryKey:B})}},e[0]=r,e[1]=i),n(i)}async function ce(e){let t=new k({DBSnapshotIdentifier:e});return await p().send(t)}function le(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:ue,onSuccess:()=>{r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function ue(e){let t=new F({DBInstanceIdentifier:e.dbInstanceIdentifier,DBSnapshotIdentifier:e.dbSnapshotIdentifier,DBInstanceClass:e.dbInstanceClass,AllocatedStorage:e.allocatedStorage,Port:e.port===``?void 0:Number(e.port),DBSubnetGroupName:W(e.dbSubnetGroupName),VpcSecurityGroupIds:e.vpcSecurityGroupIds.length>0?e.vpcSecurityGroupIds:void 0,DBParameterGroupName:W(e.dbParameterGroupName),DeletionProtection:e.deletionProtection,Tags:G(e.tags)});return await p().send(t)}function de(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:fe,onSuccess:()=>{r.invalidateQueries({queryKey:H})}},e[0]=r,e[1]=i),n(i)}async function fe(e){let t=new E({DBSubnetGroupName:e.dbSubnetGroupName,DBSubnetGroupDescription:e.dbSubnetGroupDescription,SubnetIds:e.subnetIds,Tags:G(e.tags)});return await p().send(t)}function pe(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:me,onSuccess:()=>{r.invalidateQueries({queryKey:H})}},e[0]=r,e[1]=i),n(i)}async function me(e){let t=new A({DBSubnetGroupName:e});return await p().send(t)}function he(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:ge,onSuccess:()=>{r.invalidateQueries({queryKey:U})}},e[0]=r,e[1]=i),n(i)}async function ge(e){let t=new w({DBParameterGroupName:e.dbParameterGroupName,DBParameterGroupFamily:e.dbParameterGroupFamily,Description:e.description,Tags:G(e.tags)});return await p().send(t)}function _e(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:ve,onSuccess:(e,t)=>{r.invalidateQueries({queryKey:[`rds`,`parameters`,t.dbParameterGroupName]}),r.invalidateQueries({queryKey:z})}},e[0]=r,e[1]=i),n(i)}async function ve(e){let t=new M({DBParameterGroupName:e.dbParameterGroupName,Parameters:e.parameters.map(ye)});return await p().send(t)}function ye(e){return{ParameterName:e.name,ParameterValue:e.value,ApplyMethod:e.applyMethod}}function be(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:xe,onSuccess:()=>{r.invalidateQueries({queryKey:U})}},e[0]=r,e[1]=i),n(i)}async function xe(e){let t=new O({DBParameterGroupName:e});return await p().send(t)}function Se(){let e=(0,R.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:Ce,onSuccess:()=>{r.invalidateQueries({queryKey:[`rds`,`tags`]})}},e[0]=r,e[1]=i),n(i)}async function Ce(e){let t=new Set(e.tags.map(Te)),n=e.initialKeys.filter(e=>!t.has(e)),r=p();e.tags.length>0&&await r.send(new S({ResourceName:e.resourceName,Tags:e.tags.map(we)})),n.length>0&&await r.send(new P({ResourceName:e.resourceName,TagKeys:n}))}function we(e){return{Key:e.key,Value:e.value}}function Te(e){return e.key}export{X as a,pe as c,re as d,le as f,Se as h,de as i,J as l,te as m,he as n,be as o,Q as p,ae as r,se as s,K as t,_e as u}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/oxlint.config.ts b/spinifex/services/spinifexui/frontend/oxlint.config.ts index fd38cf463..19a328525 100644 --- a/spinifex/services/spinifexui/frontend/oxlint.config.ts +++ b/spinifex/services/spinifexui/frontend/oxlint.config.ts @@ -53,6 +53,7 @@ export default defineConfig({ "react-you-might-not-need-an-effect/no-external-store-subscription": "error", "react-you-might-not-need-an-effect/no-initialize-state": "error", + "react-you-might-not-need-an-effect/no-pass-data-to-parent": "error", "react-you-might-not-need-an-effect/no-pass-live-state-to-parent": "error", "react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change": "error", diff --git a/spinifex/services/spinifexui/frontend/src/mutations/rds.test.tsx b/spinifex/services/spinifexui/frontend/src/mutations/rds.test.tsx index 24b23b43c..decae5c01 100644 --- a/spinifex/services/spinifexui/frontend/src/mutations/rds.test.tsx +++ b/spinifex/services/spinifexui/frontend/src/mutations/rds.test.tsx @@ -154,6 +154,34 @@ describe("useModifyDBInstance", () => { // An unset password must not be sent as an empty reset. expect(input.MasterUserPassword).toBeUndefined() }) + + it("invalidates the automated backups and snapshots as well as the instance", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useModifyDBInstance(), { wrapper }) + + result.current.mutate({ + dbInstanceIdentifier: "orders-db", + currentAllocatedStorage: 20, + dbInstanceClass: "db.t3.micro", + allocatedStorage: 20, + dbParameterGroupName: "", + vpcSecurityGroupIds: [], + deletionProtection: false, + backupRetentionPeriod: 0, + preferredBackupWindow: "", + preferredMaintenanceWindow: "", + masterUserPassword: "", + applyImmediately: true, + }) + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + expect(spy).toHaveBeenCalledWith({ queryKey: ["rds", "dbInstances"] }) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["rds", "automatedBackups", "orders-db"], + }) + expect(spy).toHaveBeenCalledWith({ queryKey: ["rds", "dbSnapshots"] }) + }) }) describe("useDeleteDBInstance", () => { @@ -190,6 +218,27 @@ describe("useDeleteDBInstance", () => { expect(input.SkipFinalSnapshot).toBeTruthy() expect(input.FinalDBSnapshotIdentifier).toBeUndefined() }) + + // The final snapshot appears and the automated backups go, neither of which + // is reachable from the instance list alone. + it("invalidates the automated backups and snapshots as well as the instance", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useDeleteDBInstance(), { wrapper }) + + result.current.mutate({ + dbInstanceIdentifier: "orders-db", + skipFinalSnapshot: false, + finalSnapshotIdentifier: "orders-db-final-20260817-1200", + }) + + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + expect(spy).toHaveBeenCalledWith({ queryKey: ["rds", "dbInstances"] }) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["rds", "automatedBackups", "orders-db"], + }) + expect(spy).toHaveBeenCalledWith({ queryKey: ["rds", "dbSnapshots"] }) + }) }) describe("rds lifecycle mutations", () => { diff --git a/spinifex/services/spinifexui/frontend/src/mutations/rds.ts b/spinifex/services/spinifexui/frontend/src/mutations/rds.ts index ac5312135..2fc186774 100644 --- a/spinifex/services/spinifexui/frontend/src/mutations/rds.ts +++ b/spinifex/services/spinifexui/frontend/src/mutations/rds.ts @@ -32,6 +32,7 @@ import type { const DB_INSTANCES_KEY = ["rds", "dbInstances"] const DB_SNAPSHOTS_KEY = ["rds", "dbSnapshots"] +const AUTOMATED_BACKUPS_KEY = ["rds", "automatedBackups"] const SUBNET_GROUPS_KEY = ["rds", "subnetGroups"] const PARAMETER_GROUPS_KEY = ["rds", "parameterGroups"] @@ -105,8 +106,14 @@ export function useModifyDBInstance() { }) return await getRdsClient().send(command) }, - onSuccess: () => { + onSuccess: (_data, params) => { void queryClient.invalidateQueries({ queryKey: DB_INSTANCES_KEY }) + // A retention of zero purges the instance's automated backups along with + // the snapshots they hold, and neither is projected onto the instance. + void queryClient.invalidateQueries({ + queryKey: [...AUTOMATED_BACKUPS_KEY, params.dbInstanceIdentifier], + }) + void queryClient.invalidateQueries({ queryKey: DB_SNAPSHOTS_KEY }) }, }) } @@ -130,8 +137,14 @@ export function useDeleteDBInstance() { }) return await getRdsClient().send(command) }, - onSuccess: () => { + onSuccess: (_data, params) => { void queryClient.invalidateQueries({ queryKey: DB_INSTANCES_KEY }) + // The teardown takes the instance's automated backups with it and leaves + // the final snapshot behind, so both snapshot views are stale too. + void queryClient.invalidateQueries({ + queryKey: [...AUTOMATED_BACKUPS_KEY, params.dbInstanceIdentifier], + }) + void queryClient.invalidateQueries({ queryKey: DB_SNAPSHOTS_KEY }) }, }) } diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/rds/(db-instances)/-components/db-instance-detail-page.test.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/rds/(db-instances)/-components/db-instance-detail-page.test.tsx index 20aeb98ed..1ce30f36b 100644 --- a/spinifex/services/spinifexui/frontend/src/routes/_auth/rds/(db-instances)/-components/db-instance-detail-page.test.tsx +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/rds/(db-instances)/-components/db-instance-detail-page.test.tsx @@ -1,6 +1,6 @@ import type { QueryClient } from "@tanstack/react-query" import { fireEvent, screen, waitFor, within } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { formatDateTime } from "@/lib/utils" import { @@ -146,6 +146,10 @@ function openTab(name: string) { } describe("DBInstanceDetailPage", () => { + beforeEach(() => { + mockSend.mockResolvedValue({}) + }) + it("renders the identifier and status", () => { renderWithClient( , @@ -366,6 +370,43 @@ describe("DBInstanceDetailPage", () => { expect(screen.getByText("active")).toBeInTheDocument() }) + // The status is the automated backup's own, so nothing the instance describe + // returns can refresh it. + it("refreshes the automated backup status after a modify", async () => { + mockSend.mockImplementation(async (command: object) => { + if (command.constructor.name === "DescribeDBInstancesCommand") { + return Promise.resolve({ + DBInstances: [{ ...INSTANCE, BackupRetentionPeriod: 0 }], + }) + } + if ( + command.constructor.name === "DescribeDBInstanceAutomatedBackupsCommand" + ) { + return Promise.resolve({ DBInstanceAutomatedBackups: [] }) + } + return Promise.resolve({}) + }) + renderWithClient( + , + seed({ automatedBackups: [{ Status: "creating" }] }), + ) + openTab("Backups") + expect(screen.getByText("creating")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Modify" })) + fireEvent.change(screen.getByLabelText("Backup retention (days)"), { + target: { value: "0" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Save Changes" })) + + await waitFor(() => + expect( + screen.getByText("None — automated backups are off"), + ).toBeInTheDocument(), + ) + expect(screen.getByText("Disabled")).toBeInTheDocument() + }) + it("says automated backups are off when the backend reports none", () => { renderWithClient( , diff --git a/spinifex/services/spinifexui/frontend/src/test/setup.ts b/spinifex/services/spinifexui/frontend/src/test/setup.ts index 560b3a75c..22617c79a 100644 --- a/spinifex/services/spinifexui/frontend/src/test/setup.ts +++ b/spinifex/services/spinifexui/frontend/src/test/setup.ts @@ -1,3 +1,4 @@ +import type * as TestingLibraryReact from "@testing-library/react" import { cleanup } from "@testing-library/react" import "@testing-library/jest-dom/vitest" import { afterEach, vi } from "vitest" @@ -8,6 +9,19 @@ afterEach(() => { cleanup() }) +// waitFor wakes on DOM mutations, otherwise it polls every 50ms. renderHook +// tests settle without touching the DOM, so each one pays a full tick. +vi.mock("@testing-library/react", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + waitFor: async ( + callback: Parameters[0], + options?: Parameters[1], + ) => await actual.waitFor(callback, { interval: 1, ...options }), + } +}) + // Components sign requests with the region the server reports, which the app // normally loads before rendering. Seed it through the real code path so every // component test has one without stubbing fetch itself. diff --git a/spinifex/services/spinifexui/frontend/vite.config.ts b/spinifex/services/spinifexui/frontend/vite.config.ts index 59fd3bd7c..e38a04b5f 100644 --- a/spinifex/services/spinifexui/frontend/vite.config.ts +++ b/spinifex/services/spinifexui/frontend/vite.config.ts @@ -8,7 +8,7 @@ import basicSsl from "@vitejs/plugin-basic-ssl" import react, { reactCompilerPreset } from "@vitejs/plugin-react" import { defineConfig } from "vite" -export default defineConfig({ +export default defineConfig(({ mode }) => ({ envDir: "../", build: { target: "es2023", @@ -29,9 +29,7 @@ export default defineConfig({ routeFileIgnorePattern: "\\.test\\.(ts|tsx)$", }), react(), - babel({ - presets: [reactCompilerPreset()], - }), + ...(mode === "test" ? [] : [babel({ presets: [reactCompilerPreset()] })]), tailwindcss(), ], resolve: { @@ -44,6 +42,7 @@ export default defineConfig({ environment: "happy-dom", setupFiles: "./src/test/setup.ts", clearMocks: true, + pool: "threads", coverage: { include: ["src/**/*.{ts,tsx}"], exclude: [ @@ -61,4 +60,4 @@ export default defineConfig({ }, }, }, -}) +})) diff --git a/spinifex/systemd/units_gen.go b/spinifex/systemd/units_gen.go index 316a1c778..7150f7467 100644 --- a/spinifex/systemd/units_gen.go +++ b/spinifex/systemd/units_gen.go @@ -15,7 +15,7 @@ var Units = map[string]string{ "spinifex-nats-watchdog.timer": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex NATS JetStream liveness watchdog timer\nPartOf=spinifex.target\n\n[Timer]\n# Delay the first probe so NATS startup + JetStream cluster quorum settle (a\n# forming cluster reports JS not-current transiently, which is not a fault).\nOnBootSec=60s\nOnUnitActiveSec=30s\nAccuracySec=5s\n\n[Install]\nWantedBy=timers.target\n", "spinifex-nats.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex NATS Message Bus\nPartOf=spinifex.target\nBefore=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-nats\nGroup=spinifex\nExecStart=/usr/local/bin/spx service nats start\nRestart=on-failure\nRestartSec=5\n# nats-server installs its own SIGTERM handler (JetStream store shutdown, raft\n# stepdown, client drain) with no internal deadline; every dependent service\n# stops before nats does, so size this for nats's own graceful-stop needs.\nTimeoutStopSec=20\nOOMScoreAdjust=-500\n# Per-service ceiling bounds a leak in this service alone rather than\n# tripping the slice-wide cap, which would pick an arbitrary victim.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/nats /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/nats /var/log/spinifex\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nCapabilityBoundingSet=\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n", "spinifex-northstar.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex Northstar (Authoritative DNS)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-predastore.service\nWants=spinifex-predastore.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-northstar\nGroup=spinifex\nExecStart=/usr/local/bin/spx service northstar start\nRestart=on-failure\nRestartSec=5\n# server.Shutdown() is called with an internal 5s context; margin covers the\n# NATS unsubscribe/close and process exit that follow it.\nTimeoutStopSec=10\nOOMScoreAdjust=-500\n# Per-service ceiling bounds a leak in this service alone rather than\n# tripping the slice-wide cap, which would pick an arbitrary victim.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/northstar/\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/northstar /etc/spinifex/spinifex.toml /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/northstar /var/log/spinifex\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\n# RG-9: exactly CAP_NET_BIND_SERVICE so the unprivileged user binds :53 (no root).\nAmbientCapabilities=CAP_NET_BIND_SERVICE\nCapabilityBoundingSet=CAP_NET_BIND_SERVICE\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n", - "spinifex-predastore.service": "# spinifex-unit-version: 2\n[Unit]\nDescription=Spinifex Predastore (S3-compatible Object Storage)\nPartOf=spinifex.target\nAfter=spinifex-nats.service\nWants=spinifex-nats.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\n# Guest block I/O arrives with no caller trace; sample those roots at 5% so\n# they don't drown request traces. Request-linked spans are always kept.\nEnvironment=MULGA_ROOT_TRACE_RATIO=0.05\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-storage\nGroup=spinifex\nExecStartPre=/var/lib/spinifex/wait-for-nats.sh\nExecStart=/usr/local/bin/spx service predastore start\nRestart=on-failure\nRestartSec=5\n# Bounds predastore's own graceful stop. viperblock's WAL flush writes into\n# predastore, but After= already stops viperblock first (within its own 150s\n# budget); this is the backstop if that ordering is ever bypassed.\nTimeoutStopSec=30\nOOMScoreAdjust=-500\n# Legitimately the most memory-hungry infra service (S3 buffering/caching);\n# High/Max stay well above idle so normal load isn't throttled, while still\n# bounding a leak short of the slice-wide cap.\nMemoryHigh=40%\nMemoryMax=55%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_PREDASTORE_CONFIG_PATH=/etc/spinifex/predastore/predastore.toml\nEnvironment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/\nEnvironment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem\nEnvironment=SPINIFEX_PREDASTORE_TLS_KEY=/etc/spinifex/server.key\nEnvironment=SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE=/etc/spinifex/predastore/encryption.key\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/predastore /var/log/spinifex\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nCapabilityBoundingSet=\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n", + "spinifex-predastore.service": "# spinifex-unit-version: 3\n[Unit]\nDescription=Spinifex Predastore (S3-compatible Object Storage)\nPartOf=spinifex.target\nAfter=spinifex-nats.service\nWants=spinifex-nats.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\n# No MULGA_ROOT_TRACE_RATIO here: the sink's tail sampler sheds guest block I/O\n# instead, and it can see whether a trace errored. A head ratio decides before\n# the error exists, so it discarded failures at the same rate as successes.\nType=simple\nSlice=spinifex-system.slice\nUser=spinifex-storage\nGroup=spinifex\nExecStartPre=/var/lib/spinifex/wait-for-nats.sh\nExecStart=/usr/local/bin/spx service predastore start\nRestart=on-failure\nRestartSec=5\n# Bounds predastore's own graceful stop. viperblock's WAL flush writes into\n# predastore, but After= already stops viperblock first (within its own 150s\n# budget); this is the backstop if that ordering is ever bypassed.\nTimeoutStopSec=30\nOOMScoreAdjust=-500\n# Legitimately the most memory-hungry infra service (S3 buffering/caching);\n# High/Max stay well above idle so normal load isn't throttled, while still\n# bounding a leak short of the slice-wide cap.\nMemoryHigh=40%\nMemoryMax=55%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_PREDASTORE_CONFIG_PATH=/etc/spinifex/predastore/predastore.toml\nEnvironment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/\nEnvironment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem\nEnvironment=SPINIFEX_PREDASTORE_TLS_KEY=/etc/spinifex/server.key\nEnvironment=SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE=/etc/spinifex/predastore/encryption.key\n\n# Filesystem access\nNoNewPrivileges=yes\nProtectSystem=strict\nPrivateDevices=yes\nReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/predastore /var/log/spinifex\n\n# Hardening\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectProc=invisible\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK\nCapabilityBoundingSet=\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=4096\n\n[Install]\nWantedBy=spinifex.target\n", "spinifex-qmp-collector.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex QMP Telemetry Collector (guest metrics)\nPartOf=spinifex.target\nAfter=spinifex-nats.service spinifex-daemon.service\nWants=spinifex-nats.service\n\n[Service]\nEnvironmentFile=-/etc/spinifex/telemetry.env\nType=simple\nSlice=spinifex-system.slice\n# spinifex-daemon user: the collector reads the daemon's telemetry QMP\n# sockets in /run/spinifex and the daemon-owned QEMU /proc entries.\nUser=spinifex-daemon\nGroup=spinifex\nExecStartPre=/var/lib/spinifex/wait-for-nats.sh\nExecStart=/usr/local/bin/spx service qmp-collector start\nRestart=on-failure\nRestartSec=5\n# Poll loop selects on ctx.Done() directly and closes NATS locally; shutdown\n# is near-instant, so this only bounds an unexpectedly wedged process.\nTimeoutStopSec=10\n# Per-service ceiling bounds a leak in this service alone rather than\n# tripping the slice-wide cap, which would pick an arbitrary victim.\nMemoryHigh=10%\nMemoryMax=20%\nEnvironment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml\nEnvironment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/\n# Same runtime dir as the daemon: telemetry sockets + metadata live there\nEnvironment=XDG_RUNTIME_DIR=/run/spinifex\n\n# RG-9: read-mostly telemetry tier — no capabilities, no device access\nProtectSystem=full\nPrivateDevices=yes\nNoNewPrivileges=yes\nReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/ca.pem\nReadWritePaths=/var/lib/spinifex/spinifex /run/spinifex\n\n# RG-9: hardening baseline\nProtectHome=yes\nPrivateTmp=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRestrictRealtime=yes\nRestrictNamespaces=yes\nProtectClock=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\n# ProtectProc must stay default: hidepid=invisible hides the QEMU vCPU threads\n# (dumpable=0) this collector must read from /proc//stat, even same-uid.\nSystemCallArchitectures=native\nMemoryDenyWriteExecute=yes\nRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX\nProtectHostname=yes\nKeyringMode=private\nRemoveIPC=yes\nLimitCORE=0\nLimitNOFILE=65536\nLimitNPROC=512\n\n[Install]\nWantedBy=spinifex.target\n", "spinifex-shutdown.service": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex graceful guest drain\nPartOf=spinifex.target\n# After= all services so it stops FIRST on target/host shutdown: the drain runs\n# while nats/predastore/viperblock/daemon are still up (QMP, unmount, WAL flush).\nAfter=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/bin/true\n# ExecStop fires on ANY target stop, including the stop half of `systemctl\n# restart spinifex.target` (a rolling deploy) -- PartOf propagates regardless\n# of whether the host is actually going down. --unless-restarting drains on a\n# real shutdown/reboot and on a plain `systemctl stop spinifex.target`; it\n# skips only when a restart of the stack is already queued, so QEMU/nbdkit\n# survive (KillMode=process) and the daemon reconnects on the way back up.\nExecStop=/usr/local/bin/spx admin node drain --local --timeout=120s --unless-restarting\nTimeoutStopSec=180\n\n[Install]\nWantedBy=spinifex.target\n", "spinifex-system.slice": "# spinifex-unit-version: 1\n[Unit]\nDescription=Spinifex control/infra tier (NATS, predastore, viperblock, daemon, awsgw, vpcd, ui)\nBefore=slices.target\n\n[Slice]\n# RG-3: child of spinifex.slice (spinifex-system -> spinifex), inherits the\n# parent ceiling, no extra limit. Holds the 7 control/infra services. Guest QEMU\n# moves to the sibling spinifex-guests.slice (per-instance placement lands in\n# 0002 RG-7); until then the daemon-forked guests stay under spinifex-daemon.service.\n", diff --git a/spinifex/systemd/units_invariants_test.go b/spinifex/systemd/units_invariants_test.go index b917d55bd..7a6f41b58 100644 --- a/spinifex/systemd/units_invariants_test.go +++ b/spinifex/systemd/units_invariants_test.go @@ -436,6 +436,7 @@ var unitBodyHashes = map[string]map[int]string{ "spinifex-predastore.service": { 1: "d0f6415b8f0e6ff3c045f2d3ce2794c347bf141066d7e0bd85fcec48797854d8", 2: "157e9a7683ac58760ad96679cad9f94121294f34d5cb668d1e586ba0686b4968", + 3: "f5448bc2aabd3cf1af9dc74f0f12954a9294069da9f5115f8b95b529a070cbdc", }, "spinifex-qmp-collector.service": {1: "beb18e6dd9351901f19d992cb2f757fb0e0e4a4d986402ccdb0ebb0a449f225c"}, "spinifex-shutdown.service": {1: "bcdc455916f35aa7494b2fe25e691339e8f1e22f031dfd9fd95203a9aa4bdaa4"}, diff --git a/spinifex/utils/utils.go b/spinifex/utils/utils.go index 640530917..e6388e850 100644 --- a/spinifex/utils/utils.go +++ b/spinifex/utils/utils.go @@ -31,6 +31,7 @@ import ( func GenerateResourceID(prefix string) string { b := make([]byte, 9) if _, err := rand.Read(b); err != nil { + //nolint:forbidigo // Continuing after the CSPRNG fails could create predictable resource IDs. panic("crypto/rand failed: " + err.Error()) } return prefix + "-" + hex.EncodeToString(b)[:17] diff --git a/tests/e2e/harness/diag.go b/tests/e2e/harness/diag.go index c324555e1..9a43a59a2 100644 --- a/tests/e2e/harness/diag.go +++ b/tests/e2e/harness/diag.go @@ -54,24 +54,41 @@ func DumpVPCFlowDiagnostics(t *testing.T, c *AWSClient, instanceID, label string fmt.Printf("%s%s── END DIAGNOSTICS ──%s\n\n", colorBold, colorCyan, colorReset) } -func dumpConsoleOutput(t *testing.T, c *AWSClient, instanceID string) { - t.Helper() +// InstanceConsole returns an instance's serial console, base64-decoded. +// A payload that fails to decode is returned verbatim — the console is a +// diagnostic channel and a partially-written one is still worth reading. +// The error is returned rather than logged so a poll loop can fold it into +// its own timeout message instead of repeating it every interval. +func InstanceConsole(c *AWSClient, instanceID string) (string, error) { out, err := c.EC2.GetConsoleOutput(&ec2.GetConsoleOutputInput{ InstanceId: aws.String(instanceID), }) if err != nil { - fmt.Printf(" console-output: GetConsoleOutput failed: %v\n", err) + return "", fmt.Errorf("get-console-output %s: %w", instanceID, err) + } + encoded := aws.StringValue(out.Output) + if encoded == "" { + return "", nil + } + raw, derr := base64.StdEncoding.DecodeString(encoded) + if derr != nil { + return encoded, nil + } + return string(raw), nil +} + +func dumpConsoleOutput(t *testing.T, c *AWSClient, instanceID string) { + t.Helper() + console, err := InstanceConsole(c, instanceID) + if err != nil { + fmt.Printf(" console-output: %v\n", err) return } - if out.Output == nil || aws.StringValue(out.Output) == "" { + if console == "" { fmt.Println(" console-output: empty") return } - raw, derr := base64.StdEncoding.DecodeString(aws.StringValue(out.Output)) - if derr != nil { - raw = []byte(aws.StringValue(out.Output)) - } - tail := raw + tail := []byte(console) if len(tail) > 4096 { tail = tail[len(tail)-4096:] } @@ -88,23 +105,16 @@ func dumpConsoleOutput(t *testing.T, c *AWSClient, instanceID string) { // CI artifacts alone. Best-effort: failures log, they don't fail the test. func DumpInstanceConsole(t *testing.T, c *AWSClient, instanceID, dir, name string) { t.Helper() - out, err := c.EC2.GetConsoleOutput(&ec2.GetConsoleOutputInput{ - InstanceId: aws.String(instanceID), - }) + console, err := InstanceConsole(c, instanceID) if err != nil { - t.Logf("console capture: GetConsoleOutput(%s) failed: %v", instanceID, err) + t.Logf("console capture: %v", err) return } - encoded := aws.StringValue(out.Output) - if encoded == "" { + if console == "" { t.Logf("console capture: %s empty", instanceID) return } - raw, derr := base64.StdEncoding.DecodeString(encoded) - if derr != nil { - raw = []byte(encoded) - } - DumpFile(t, dir, name, raw) + DumpFile(t, dir, name, []byte(console)) } func dumpOVNState(t *testing.T, instanceID string) { diff --git a/tests/e2e/harness/rds.go b/tests/e2e/harness/rds.go index daafe5d9d..7034f359d 100644 --- a/tests/e2e/harness/rds.go +++ b/tests/e2e/harness/rds.go @@ -5,12 +5,14 @@ package harness import ( "encoding/json" "fmt" + "strings" "testing" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/rds" + "github.com/mulgadc/spinifex/spinifex/daemon" handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds" "github.com/mulgadc/spinifex/spinifex/tags" "github.com/mulgadc/spinifex/spinifex/utils" @@ -316,6 +318,91 @@ func dbInstanceVolumes(system *AWSClient, id string) ([]*ec2.Volume, error) { return out.Volumes, nil } +// DBSystemENI returns the DB VM's management NIC in the shared RDS system VPC, +// read with the system credentials that own it. +// +// This is the NIC no customer security group governs and no customer can see, +// which is exactly why an isolation assertion has to look at it: every DB VM in +// the deployment, across every account, has one in the same VPC. +func DBSystemENI(t *testing.T, system *AWSClient, id string) *ec2.NetworkInterface { + t.Helper() + out, err := system.EC2.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ + Filters: []*ec2.Filter{{ + Name: aws.String("description"), + Values: []*string{aws.String("RDS management NIC for " + id)}, + }}, + }) + if err != nil { + t.Fatalf("DBSystemENI %s: describe-network-interfaces: %v", id, err) + } + if len(out.NetworkInterfaces) != 1 { + t.Fatalf("DBSystemENI %s: found %d matching ENIs, want 1", id, len(out.NetworkInterfaces)) + } + return out.NetworkInterfaces[0] +} + +// The prefix daemon namespaces its management-IP allocations under in the +// cluster-state bucket. Duplicated rather than imported because the daemon keeps +// the key unexported. +const mgmtIPAMKeyPrefix = "mgmt-ipam." + +// DBInstanceMgmtIP resolves a DB VM's br-mgmt address from the cluster's +// management IPAM record. +// +// No API exposes it: the management NIC is host-local plumbing rather than a VPC +// interface. The runner sits on the same bridge, which makes this the one path +// from which a DB VM's engine port can be dialled without a customer VPC — and +// so the one place the bind can be proven from outside the guest. +func DBInstanceMgmtIP(t *testing.T, env *Env, system *AWSClient, id string) string { + t.Helper() + vm := DBInstanceVM(t, system, id) + instanceID := aws.StringValue(vm.InstanceId) + + host, token, ca := natsConn(t, env) + nc, err := utils.ConnectNATS(host, token, ca) + if err != nil { + t.Fatalf("DBInstanceMgmtIP %s: connect NATS %s: %v", id, host, err) + } + defer nc.Close() + + js, err := jetstream.New(nc) + if err != nil { + t.Fatalf("DBInstanceMgmtIP %s: jetstream context: %v", id, err) + } + kv, err := js.KeyValue(t.Context(), daemon.ClusterStateBucket) + if err != nil { + t.Fatalf("DBInstanceMgmtIP %s: open %s: %v", id, daemon.ClusterStateBucket, err) + } + keys, err := kv.Keys(t.Context()) + if err != nil { + t.Fatalf("DBInstanceMgmtIP %s: list %s: %v", id, daemon.ClusterStateBucket, err) + } + + // br-mgmt can be more than one /24 across a cluster, so every allocation + // record is scanned rather than the local node's. + for _, key := range keys { + if !strings.HasPrefix(key, mgmtIPAMKeyPrefix) { + continue + } + entry, err := kv.Get(t.Context(), key) + if err != nil { + t.Fatalf("DBInstanceMgmtIP %s: read %s: %v", id, key, err) + } + var rec daemon.MgmtIPRecord + if err := json.Unmarshal(entry.Value(), &rec); err != nil { + t.Fatalf("DBInstanceMgmtIP %s: decode %s: %v", id, key, err) + } + for _, alloc := range rec.Allocated { + if alloc.InstanceID == instanceID && alloc.IP != "" { + t.Logf("DB instance %s VM %s has mgmt address %s", id, instanceID, alloc.IP) + return alloc.IP + } + } + } + t.Fatalf("DBInstanceMgmtIP %s: VM %s holds no management IP allocation", id, instanceID) + return "" +} + // dbEndpointENIs finds a DB instance's endpoint ENI in the tenant account. ENIs // ignore tag filters, so the description the control plane sets at launch is the // only discriminator available. diff --git a/tests/e2e/natuplink/natuplink_test.go b/tests/e2e/natuplink/natuplink_test.go index f5dc93337..59fbaf84d 100644 --- a/tests/e2e/natuplink/natuplink_test.go +++ b/tests/e2e/natuplink/natuplink_test.go @@ -45,22 +45,33 @@ const ( natExemptSetName = "spinifex_nat_exempt" ) +// A failing iteration costs two 10 s curl timeouts, a 3 s ping and a 5 s +// sleep — ~28 s measured on the console clock. The verdict budget must cover +// the whole loop plus boot and cloud-init, or the fail marker never prints +// inside the window the test watches. +const ( + egressProbeIterations = 10 + egressIterationBudget = 30 * time.Second + egressBootBudget = 3 * time.Minute + egressVerdictBudget = egressProbeIterations*egressIterationBudget + egressBootBudget +) + // egressUserData probes outbound WAN reachability from inside the guest and // reports the verdict on the serial console — the only channel back to the // test in nat mode. ping 8.8.8.8 is the DNS-free fallback; the curl targets // additionally prove DHCP-delivered DNS works through the masquerade. -const egressUserData = `#!/bin/bash -for i in $(seq 1 36); do +var egressUserData = fmt.Sprintf(`#!/bin/bash +for i in $(seq 1 %d); do if curl -fsS -m 10 -o /dev/null http://connectivity-check.ubuntu.com/ \ || curl -fsS -m 10 -o /dev/null http://www.google.com/generate_204 \ || ping -c 1 -W 3 8.8.8.8 >/dev/null 2>&1; then - echo "NAT-E2E-EGRESS-OK" | tee /dev/console + echo "%s" | tee /dev/console exit 0 fi sleep 5 done -echo "NAT-E2E-EGRESS-FAIL" | tee /dev/console -` +echo "%s" | tee /dev/console +`, egressProbeIterations, egressOKMarker, egressFailMarker) type fixture struct { env *harness.Env @@ -303,6 +314,10 @@ type egressProbe struct { func phaseInstanceEgress(t *testing.T, fix *fixture, def harness.VPCInfo) egressProbe { t.Helper() + // Whether the gateway router resolved the transit nexthop is the decisive + // question when egress fails, and nothing else in the bundle answers it. + harness.OnFailure(t, func() { dumpOVNRouting(t, fix.artifacts, def.VPCID) }) + instType, arch := harness.DiscoverNanoInstanceType(t, fix.harness) amiID := harness.DiscoverUbuntuAMI(t, fix.harness, arch) keyName, _ := harness.EnsureKeyPair(t, fix.harness, fix.artifacts) @@ -348,16 +363,20 @@ func phaseInstanceEgress(t *testing.T, fix *fixture, def harness.VPCInfo) egress harness.Step(t, "wait for egress verdict on serial console") var console string + // Registered before the wait so a budget overrun — which Fatals inside + // EventuallyErr — still leaves the last console read behind. + harness.OnFailure(t, func() { + harness.DumpFile(t, fix.artifacts, "egress-console.log", []byte(console)) + }) harness.EventuallyErr(t, func() error { console = consoleOutput(t, fix, instanceID) if strings.Contains(console, egressOKMarker) || strings.Contains(console, egressFailMarker) { return nil } return fmt.Errorf("no egress marker on console yet (%d bytes)", len(console)) - }, 8*time.Minute, 10*time.Second) + }, egressVerdictBudget, 10*time.Second) if strings.Contains(console, egressFailMarker) { - harness.DumpFile(t, fix.artifacts, "egress-console.log", []byte(console)) t.Fatalf("guest reported %s — outbound WAN unreachable through routed NAT (console saved to artifacts)", egressFailMarker) } harness.Step(t, "guest reported %s", egressOKMarker) @@ -683,6 +702,19 @@ func sshHandshake(host string) error { // --- Helpers --------------------------------------------------------------- +// dumpOVNRouting writes the gateway router's nexthop and routing state to the +// artifact bundle. Best-effort: DumpCmd records a non-zero exit rather than +// failing, so a half-built router still yields whatever OVN has. +func dumpOVNRouting(t *testing.T, dir, vpcID string) { + t.Helper() + harness.DumpCmd(t, dir, "ovn-static-mac-binding-list.txt", + "sudo", "-n", "ovn-nbctl", "static-mac-binding-list") + harness.DumpCmd(t, dir, "ovn-sb-mac-binding.txt", + "sudo", "-n", "ovn-sbctl", "list", "MAC_Binding") + harness.DumpCmd(t, dir, "ovn-lr-route-list.txt", + "sudo", "-n", "ovn-nbctl", "lr-route-list", "vpc-"+vpcID) +} + // hostCmd runs a local (non-sudo) command and fails the test on error. func hostCmd(t *testing.T, name string, args ...string) string { t.Helper() diff --git a/tests/e2e/rds/isolation_test.go b/tests/e2e/rds/isolation_test.go new file mode 100644 index 000000000..b4337816e --- /dev/null +++ b/tests/e2e/rds/isolation_test.go @@ -0,0 +1,163 @@ +//go:build e2e + +package rds + +import ( + "errors" + "fmt" + "net" + "syscall" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Long enough that a slow datapath is not read as a refusal, short enough that +// four probes do not dominate the test. +const isolationDialTimeout = 5 * time.Second + +// TestDBVMIsolation asserts that a DB instance's endpoint is the only path to +// its engine. +// +// A DB VM has three NICs. Only one of them — the customer ENI the endpoint +// resolves to — is governed by a customer security group. The other two are the +// system NIC in the RDS system VPC every DB VM in the deployment shares, and a +// management NIC on the host's br-mgmt bridge, which is plain L2 with no ACLs at +// all. An engine bound to the wildcard is therefore reachable on both, from any +// account, and the customer's security group is not consulted. +// +// Two DB instances in two tenants, because one instance can only show that a +// path is closed to the runner; two show it is closed between tenants, which is +// what the exposure actually was. +func TestDBVMIsolation(t *testing.T) { + f := requireRDSFixture(t) + t.Parallel() + reserveDBVMs(t, dbClass, dbClass) + + suffix := time.Now().Unix() + mine := fmt.Sprintf("%s-iso-a-%d", dbInstancePfx, suffix) + theirs := fmt.Sprintf("%s-iso-b-%d", dbInstancePfx, suffix) + + harness.Phase(t, "Creating DB instance %q in this tenant", mine) + createDBInstance(t, f, mine) + + harness.Phase(t, "Creating DB instance %q in a second tenant", theirs) + other := isolationSecondTenant(t, f, suffix) + _, err := other.RDS.CreateDBInstance(validCreateInput(theirs)) //nolint:staticcheck // e2e:allow-create — the second tenant's own instance + require.NoError(t, err, "create-db-instance %s in the second tenant", theirs) + t.Cleanup(func() { deleteInstanceAs(t, other, theirs) }) + + instance := waitForAvailable(t, f, mine) + harness.WaitForDBInstanceAvailable(t, other, theirs) + enginePort := aws.Int64Value(instance.Endpoint.Port) + require.NotZero(t, enginePort, "an available instance must publish an endpoint port") + + system := f.SystemAWS(t) + + // The direct proof, and the only leg that covers the co-location path: the + // runner sits on br-mgmt and can address any DB VM's management NIC on it, + // with no security group and no VPC in the way. A wildcard bind answers here. + t.Run("TheEngineIsNotBoundOnTheManagementBridge", func(t *testing.T) { + for _, id := range []string{mine, theirs} { + mgmtIP := harness.DBInstanceMgmtIP(t, f.Env, system, id) + requireRunnerOnMgmtBridge(t, mgmtIP) + + conn, err := net.DialTimeout("tcp", net.JoinHostPort(mgmtIP, fmt.Sprint(enginePort)), isolationDialTimeout) + if err == nil { + _ = conn.Close() + t.Errorf("%s: the engine answered on the management bridge at %s:%d; "+ + "that NIC is governed by no security group, so this is reachable from every DB VM on the host", + id, mgmtIP, enginePort) + continue + } + if !errors.Is(err, syscall.ECONNREFUSED) { + t.Errorf("%s: management bridge %s:%d could not be tested: want connection refused, got %v", + id, mgmtIP, enginePort, err) + continue + } + t.Logf("%s: management bridge %s:%d refused as expected", id, mgmtIP, enginePort) + } + }) + + // The second layer, asserted from the control plane: even a wildcard bind must + // not be admitted on the system NIC. Both instances' system NICs live in the + // same shared VPC, so the group they carry is what separates them. + t.Run("TheSystemNICCarriesAnIngressFreeGroup", func(t *testing.T) { + want := handlers_rds.SystemSecurityGroupName(f.Region) + + var vpcIDs []string + for _, id := range []string{mine, theirs} { + eni := harness.DBSystemENI(t, system, id) + vpcIDs = append(vpcIDs, aws.StringValue(eni.VpcId)) + + require.Len(t, eni.Groups, 1, "%s: the system NIC must carry exactly one group", id) + group := isolationDescribeSG(t, system, aws.StringValue(eni.Groups[0].GroupId)) + + // Landing in the VPC's default group is the whole of the exposure: its + // one ingress rule admits every other member of itself, which in this + // VPC is every DB VM in the deployment, across every account. + assert.Equal(t, want, aws.StringValue(group.GroupName), + "%s: the system NIC must carry the RDS system group, not the VPC default", id) + assert.Empty(t, group.IpPermissions, + "%s: nothing dials a DB VM's system NIC, so its group must authorize no ingress", id) + } + + require.Len(t, vpcIDs, 2) + assert.Equal(t, vpcIDs[0], vpcIDs[1], + "both tenants' system NICs share one VPC, which is why the group has to be the boundary") + }) + + // The regression half: closing those paths must not have closed the one the + // customer actually uses. + t.Run("TheEndpointStillReachesTheEngine", func(t *testing.T) { + client := rdsClient(t, f) + out := harness.PSQL(t, client, harness.PSQLConnFor(t, instance, dbMasterUser, dbMasterPassword, dbName), + "SELECT 1;") + assert.Contains(t, out, "1", "the endpoint must still serve the customer it is bound for") + }) +} + +// A second tenant with its own account, so the two DB VMs share nothing but the +// platform underneath them. +func isolationSecondTenant(t *testing.T, f *Fixture, suffix int64) *harness.AWSClient { + t.Helper() + carousel := harness.NewAccountCarousel() + tenant := carousel.Add(t, f.Env, "rds-e2e-isolation", + harness.SpxAdminAccountCreate(t, fmt.Sprintf("RDS E2E Isolation %d", suffix), "")) + return tenant.Client +} + +// A refused connection proves nothing if the runner has no route to br-mgmt at +// all, so the probe's premise is checked rather than assumed: the bridge is one +// flat segment across the cluster, and the runner holds an address on it. +func requireRunnerOnMgmtBridge(t *testing.T, mgmtIP string) { + t.Helper() + target := net.ParseIP(mgmtIP) + require.NotNil(t, target, "management address %q is not an IP", mgmtIP) + + addrs, err := net.InterfaceAddrs() + require.NoError(t, err, "read the runner's own interface addresses") + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && ipnet.Contains(target) { + return + } + } + t.Fatalf("the runner holds no address on the segment carrying %s, so a refused connection "+ + "would prove a missing route rather than an unbound engine", mgmtIP) +} + +func isolationDescribeSG(t *testing.T, system *harness.AWSClient, groupID string) *ec2.SecurityGroup { + t.Helper() + out, err := system.EC2.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{ + GroupIds: []*string{aws.String(groupID)}, + }) + require.NoError(t, err, "describe-security-groups %s", groupID) + require.Len(t, out.SecurityGroups, 1, "describe-security-groups %s returned no group", groupID) + return out.SecurityGroups[0] +} diff --git a/tests/e2e/single/datapath_test.go b/tests/e2e/single/datapath_test.go index 140a5def1..145b112b5 100644 --- a/tests/e2e/single/datapath_test.go +++ b/tests/e2e/single/datapath_test.go @@ -21,11 +21,32 @@ import ( // run-e2e.sh:3243-3248 (Phase 8e step 2). Passed as base64 — aws-sdk-go // expects UserData already base64-encoded, unlike the AWS CLI which encodes // plaintext for you. +// +// The trailing loop reports whether the listener actually came up on the +// serial console: target-sg has no SSH ingress, so the console is the only +// channel back to the test, and without it a slow cloud-init here reads as a +// datapath failure in AllowedTraffic below. const targetUserData = `#!/bin/bash systemd-run --unit=sge-http --description="Phase 8e HTTP server" \ /usr/bin/python3 -m http.server 8080 --bind 0.0.0.0 +for _ in $(seq 1 60); do + if curl -fsS -m 2 -o /dev/null http://127.0.0.1:8080/; then + echo "SGE-TARGET-HTTP-READY" | tee /dev/console + exit 0 + fi + sleep 1 +done +echo "SGE-TARGET-HTTP-FAIL" | tee /dev/console +systemctl status sge-http --no-pager | tee /dev/console ` +// Serial-console markers targetUserData prints once it knows whether +// python3 -m http.server bound :8080. +const ( + targetHTTPReadyMarker = "SGE-TARGET-HTTP-READY" + targetHTTPFailMarker = "SGE-TARGET-HTTP-FAIL" +) + // sgDatapathRevokeRounds returns how many times the revoke/re-authorize // ingress round-trip repeats against the same client/target pair, // overridable via SPINIFEX_SGDATAPATH_REVOKE_ROUNDS. Defaults to 1 — a @@ -49,14 +70,21 @@ func sgDatapathRevokeRounds() int { // address_set) and waits for client SSH. Every later stage depends on a // working SSH session, so its failure aborts the rest of the scenario // rather than let four more stages time out for the same reason. +// - TargetHTTPReady waits for the target guest to report its listener up +// on the serial console. AllowedTraffic below cannot pass without it, and +// a curl probe run before it would fail with "connection refused" — +// indistinguishable at a glance from an ACL that never programmed, and +// the reason this scenario used to flake. So it gates AllowedTraffic, +// which then only has to cover chassis flow install. // - AllowedTraffic proves the sg-to-sg 8080 rule actually passes traffic. // DeniedTraffic (client -> target:22) checks a different port and an -// unrelated rule, so it runs regardless of AllowedTraffic's outcome — a -// real, independent signal either way. But the revoke/restore round-trip -// below is only meaningful against a *proven* working baseline: if -// AllowedTraffic never actually worked, "traffic is now blocked after -// revoke" would trivially and misleadingly pass for the wrong reason. So -// AllowedTraffic gates the revoke rounds. +// unrelated rule — and asserts a drop, so it needs no listener at all — +// so it runs regardless of either outcome above, a real independent +// signal either way. But the revoke/restore round-trip below is only +// meaningful against a *proven* working baseline: if AllowedTraffic never +// actually worked, "traffic is now blocked after revoke" would trivially +// and misleadingly pass for the wrong reason. So AllowedTraffic gates the +// revoke rounds. // - Each revoke round's restore half only depends on the revoke API call // having actually removed the rule (tracked separately from the // ICMP-style "verify blocked" assertion) — a flaky drop-detection @@ -237,22 +265,70 @@ func runSGPolicyDatapath(t *testing.T, fix *Fixture) { t.Fatalf("PortGroupMembership stage failed; skipping every later stage that depends on client SSH") } - // --- AllowedTraffic: client -> target:8080 must succeed --- + // --- TargetHTTPReady: the target's listener is actually up --- - allowedOK := t.Run("AllowedTraffic", func(t *testing.T) { - // Retry to give target's cloud-init time to start python3 -m http.server. - // Bash uses up to 30 attempts at 2s — keep the same outer budget. - harness.Step(t, "8e-4 allowed traffic client -> target:%s:8080", targetPriv) - harness.EventuallyErr(t, func() error { - out, err := runSSHCombined(clientTgt, curlCmd) - if err != nil { - return fmt.Errorf("client -> target:8080 failed: %w (out=%q)", err, out) + targetReadyOK := t.Run("TargetHTTPReady", func(t *testing.T) { + harness.Step(t, "8e-3 wait for target-vm HTTP listener marker on the serial console") + var console string + t.Cleanup(func() { + if t.Failed() { + harness.DumpFile(t, fix.ArtifactDir(t), "target-console.log", []byte(console)) } - return nil - }, 60*time.Second, 2*time.Second) - harness.Detail(t, "step4", "allowed_traffic_ok") + }) + // Boot-and-cloud-init scale, not propagation scale: the guest's own + // user-data spends up to 60s waiting on the listener before it gives + // up and reports the failure marker. + harness.EventuallyErr(t, func() error { + var err error + console, err = harness.InstanceConsole(fix.AWS, targetID) + if err != nil { + return err + } + if strings.Contains(console, targetHTTPReadyMarker) || strings.Contains(console, targetHTTPFailMarker) { + return nil + } + return fmt.Errorf("no HTTP readiness marker on target-vm console yet (%d bytes)", len(console)) + }, 5*time.Minute, 5*time.Second) + + if strings.Contains(console, targetHTTPFailMarker) { + t.Fatalf("target-vm reported %s: python3 -m http.server never bound :8080 (console saved to artifacts)", + targetHTTPFailMarker) + } + harness.Detail(t, "step3", "target_http_ready") }) + // --- AllowedTraffic: client -> target:8080 must succeed --- + // + // Skipped outright when the listener never came up: the probe could only + // re-report that failure under a name pointing at the wrong subsystem. + allowedOK := false + if targetReadyOK { + allowedOK = t.Run("AllowedTraffic", func(t *testing.T) { + harness.Step(t, "8e-4 allowed traffic client -> target:%s:8080", targetPriv) + t.Cleanup(func() { + if t.Failed() { + harness.DumpVPCFlowDiagnostics(t, fix.AWS, targetID, + fmt.Sprintf("8e-4 target %s:8080 unreachable from client %s", targetPriv, clientPriv), + harness.VPCDiagnosticsOpts{ + LogicalIP: targetPriv, + ArtifactDir: fix.ArtifactDir(t), + }) + } + }) + // Port groups, address set and listener are all proven by here, + // so the only variable left is ovn-controller installing the + // chassis flows. Bash used 30 attempts at 2s; keep that budget. + harness.EventuallyErr(t, func() error { + out, err := runSSHCombined(clientTgt, curlCmd) + if err != nil { + return fmt.Errorf("client -> target:8080 failed: %w (out=%q)", err, out) + } + return nil + }, 60*time.Second, 2*time.Second) + harness.Detail(t, "step4", "allowed_traffic_ok") + }) + } + // --- DeniedTraffic: client -> target:22 must fail --- // // Independent of AllowedTraffic: a different port and an unrelated rule @@ -272,7 +348,7 @@ func runSGPolicyDatapath(t *testing.T, fix *Fixture) { // never actually got through, "blocked after revoke" would trivially and // misleadingly pass for the wrong reason. if !allowedOK { - t.Fatalf("AllowedTraffic never succeeded; skipping the revoke/restore round-trip since it would have nothing to detect a change from") + t.Fatalf("no proven-working 8080 baseline (target listener readiness or AllowedTraffic failed); skipping the revoke/restore round-trip since it would have nothing to detect a change from") } rounds := sgDatapathRevokeRounds()