3a3ea74d76
- transport.IsTransient: typed sentinels (ErrTransient/ErrPermanent) + standard net.Error/io errors.Is; substring matching removed - sshpush.isTransient: same typed-error classification - rotateSSHKeys: 2-phase atomic swap (stage peers -> swap local -> verify -> cleanup old); no more partial-result window - known_hosts: dial() reads stored field (was reading v0.8 path directly) - IPv6: net.JoinHostPort in proxmox SSH dial + drain splitHostPort - SSH timeouts: context.WithTimeout on peer-setup, drift, txn rollback, job restart (default 2m) - verifyCutover: orca CA pool TLS config (was default http.Client) - OIDC callback: ReadHeaderTimeout 5s (slowloris defense) - root Execute: signal.NotifyContext for SIGINT/SIGTERM (clean exit for non-watch commands) Tests: typed-error classification table, IPv6 JoinHostPort, signal handler context cancellation. ---ci--- project: orca phase: 8 milestone: v0.13 status: complete requirements: covered: [157] ---/ci---
124 lines
3.9 KiB
Go
124 lines
3.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/config"
|
|
)
|
|
|
|
type configCtxKey struct{}
|
|
|
|
var (
|
|
version = "0.1.0-dev"
|
|
gitCommit = "unknown"
|
|
buildTime = "unknown"
|
|
)
|
|
|
|
const systemNamespaceRoot = "/root/.orca"
|
|
|
|
var rootCmd = &cobra.Command{
|
|
Use: "orca",
|
|
Short: "Orca — offline/CLI-first orchestration engine",
|
|
Long: `Orca is a minimalist, offline-first, CLI-first orchestration engine
|
|
inspired by HashiCorp Nomad, prioritizing stability, security, and simplicity
|
|
over feature richness.`,
|
|
SilenceUsage: true,
|
|
SilenceErrors: true,
|
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
|
if systemNamespace {
|
|
if existing := os.Getenv("ORCA_HOME"); existing != "" && existing != systemNamespaceRoot {
|
|
return fmt.Errorf("--system conflicts with ORCA_HOME=%q (already set); unset ORCA_HOME or drop --system", existing)
|
|
}
|
|
if err := os.Setenv("ORCA_HOME", systemNamespaceRoot); err != nil {
|
|
return fmt.Errorf("set ORCA_HOME for --system: %w", err)
|
|
}
|
|
}
|
|
if configPath != "" {
|
|
cfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config %s: %w", configPath, err)
|
|
}
|
|
cmd.SetContext(context.WithValue(cmd.Context(), configCtxKey{}, cfg))
|
|
}
|
|
// P04 (T5): thread the verified operator identity into the
|
|
// command context so audit entries attribute actions to the
|
|
// real OIDC sub (or SPIFFE SVID) instead of the hardcoded
|
|
// "cli" string. currentActor reads ~/.orca/credentials.json.
|
|
cmd.SetContext(withActor(cmd.Context(), currentActor(context.Background())))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var (
|
|
jsonOutput bool
|
|
systemNamespace bool
|
|
configPath string
|
|
noDeprecationWarnings bool
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output in JSON format")
|
|
rootCmd.PersistentFlags().BoolVar(&systemNamespace, "system", false, "use system-level namespace root (/root/.orca) instead of user-level (~/.orca)")
|
|
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to config.hcl (overrides ~/.orca/config.hcl)")
|
|
rootCmd.PersistentFlags().BoolVar(&noDeprecationWarnings, "no-deprecation-warnings", false, "suppress v0.9 deprecation warnings (use during `orca upgrade` migrations)")
|
|
}
|
|
|
|
// warnDeprecated emits a v0.9 deprecation warning via slog.Warn unless
|
|
// the --no-deprecation-warnings global flag is set. Callers pass a
|
|
// human-readable message describing what changed. REQ-068.
|
|
func warnDeprecated(msg string) {
|
|
if noDeprecationWarnings {
|
|
return
|
|
}
|
|
slog.Warn(msg)
|
|
}
|
|
|
|
func configFromCtx(ctx context.Context) *config.Config {
|
|
if v, ok := ctx.Value(configCtxKey{}).(*config.Config); ok {
|
|
return v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Execute runs the root command. REQ-157 / P08 T9: it installs a
|
|
// signal.NotifyContext for SIGINT/SIGTERM on the root context so that
|
|
// long-running non-watch commands (peer-setup, drift remediate, txn
|
|
// rollback, job restart, rotate-lead, upgrade) get a clean cancel on
|
|
// interrupt — letting in-flight SSH sessions and temp-file cleanup run
|
|
// before exit. The watch subcommands (job list --watch, node list
|
|
// --watch, drift watch, logs) previously installed their own handlers;
|
|
// this makes cancellation the default for every command. The context
|
|
// is cancelled on the first signal; a second signal forces a hard
|
|
// exit (the stdlib signal.NotifyContext behaviour).
|
|
func Execute() error {
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
return rootCmd.ExecuteContext(ctx)
|
|
}
|
|
|
|
func printJSON(v any) error {
|
|
enc := json.NewEncoder(rootCmd.OutOrStdout())
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(v)
|
|
}
|
|
|
|
func printText(format string, args ...any) {
|
|
fmt.Fprintf(rootCmd.OutOrStdout(), format, args...)
|
|
}
|
|
|
|
func printResult(text string, jsonObj any) {
|
|
if jsonOutput {
|
|
_ = printJSON(jsonObj)
|
|
return
|
|
}
|
|
printText("%s\n", text)
|
|
}
|