igniter 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +217 -0
- data/docs/APPLICATION_V1.md +253 -0
- data/docs/CAPABILITIES_V1.md +207 -0
- data/docs/CONSENSUS_V1.md +477 -0
- data/docs/CONTENT_ADDRESSING_V1.md +221 -0
- data/docs/DATAFLOW_V1.md +274 -0
- data/docs/MESH_V1.md +732 -0
- data/docs/NODE_CACHE_V1.md +324 -0
- data/docs/PROACTIVE_AGENTS_V1.md +293 -0
- data/docs/SERVER_V1.md +200 -1
- data/docs/SKILLS_V1.md +213 -0
- data/docs/STORE_ADAPTERS.md +41 -13
- data/docs/TEMPORAL_V1.md +174 -0
- data/docs/TOOLS_V1.md +347 -0
- data/docs/TRANSCRIPTION_V1.md +403 -0
- data/examples/README.md +37 -0
- data/examples/consensus.rb +239 -0
- data/examples/dataflow.rb +308 -0
- data/examples/elocal_webhook.rb +1 -0
- data/examples/llm_tools.rb +237 -0
- data/examples/mesh.rb +239 -0
- data/examples/mesh_discovery.rb +267 -0
- data/examples/mesh_gossip.rb +162 -0
- data/examples/ringcentral_routing.rb +1 -1
- data/lib/igniter/agents/ai/alert_agent.rb +111 -0
- data/lib/igniter/agents/ai/chain_agent.rb +127 -0
- data/lib/igniter/agents/ai/critic_agent.rb +163 -0
- data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
- data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
- data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
- data/lib/igniter/agents/ai/observer_agent.rb +184 -0
- data/lib/igniter/agents/ai/planner_agent.rb +210 -0
- data/lib/igniter/agents/ai/router_agent.rb +131 -0
- data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
- data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
- data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
- data/lib/igniter/agents/proactive_agent.rb +208 -0
- data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
- data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
- data/lib/igniter/agents.rb +56 -0
- data/lib/igniter/application/app_config.rb +32 -0
- data/lib/igniter/application/autoloader.rb +18 -0
- data/lib/igniter/application/generator.rb +157 -0
- data/lib/igniter/application/scheduler.rb +109 -0
- data/lib/igniter/application/yml_loader.rb +39 -0
- data/lib/igniter/application.rb +174 -0
- data/lib/igniter/capabilities.rb +68 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
- data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
- data/lib/igniter/consensus/cluster.rb +183 -0
- data/lib/igniter/consensus/errors.rb +14 -0
- data/lib/igniter/consensus/executors.rb +43 -0
- data/lib/igniter/consensus/node.rb +320 -0
- data/lib/igniter/consensus/read_query.rb +30 -0
- data/lib/igniter/consensus/state_machine.rb +58 -0
- data/lib/igniter/consensus.rb +58 -0
- data/lib/igniter/content_addressing.rb +133 -0
- data/lib/igniter/contract.rb +12 -0
- data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
- data/lib/igniter/dataflow/aggregate_state.rb +77 -0
- data/lib/igniter/dataflow/diff.rb +37 -0
- data/lib/igniter/dataflow/diff_state.rb +81 -0
- data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
- data/lib/igniter/dataflow/window_filter.rb +48 -0
- data/lib/igniter/dataflow.rb +65 -0
- data/lib/igniter/dsl/contract_builder.rb +71 -7
- data/lib/igniter/executor.rb +60 -0
- data/lib/igniter/extensions/capabilities.rb +39 -0
- data/lib/igniter/extensions/content_addressing.rb +5 -0
- data/lib/igniter/extensions/dataflow.rb +117 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/integrations/llm/config.rb +48 -4
- data/lib/igniter/integrations/llm/executor.rb +221 -28
- data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
- data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
- data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
- data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
- data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
- data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
- data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
- data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
- data/lib/igniter/integrations/llm.rb +37 -1
- data/lib/igniter/memory/agent_memory.rb +104 -0
- data/lib/igniter/memory/episode.rb +29 -0
- data/lib/igniter/memory/fact.rb +27 -0
- data/lib/igniter/memory/memorable.rb +90 -0
- data/lib/igniter/memory/reflection_cycle.rb +96 -0
- data/lib/igniter/memory/reflection_record.rb +28 -0
- data/lib/igniter/memory/store.rb +115 -0
- data/lib/igniter/memory/stores/in_memory.rb +136 -0
- data/lib/igniter/memory/stores/sqlite.rb +284 -0
- data/lib/igniter/memory.rb +80 -0
- data/lib/igniter/mesh/announcer.rb +55 -0
- data/lib/igniter/mesh/config.rb +45 -0
- data/lib/igniter/mesh/discovery.rb +39 -0
- data/lib/igniter/mesh/errors.rb +31 -0
- data/lib/igniter/mesh/gossip.rb +47 -0
- data/lib/igniter/mesh/peer.rb +21 -0
- data/lib/igniter/mesh/peer_registry.rb +51 -0
- data/lib/igniter/mesh/poller.rb +77 -0
- data/lib/igniter/mesh/router.rb +109 -0
- data/lib/igniter/mesh.rb +85 -0
- data/lib/igniter/metrics/collector.rb +131 -0
- data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
- data/lib/igniter/metrics/snapshot.rb +8 -0
- data/lib/igniter/metrics.rb +37 -0
- data/lib/igniter/model/aggregate_node.rb +34 -0
- data/lib/igniter/model/collection_node.rb +3 -2
- data/lib/igniter/model/compute_node.rb +13 -0
- data/lib/igniter/model/remote_node.rb +18 -2
- data/lib/igniter/node_cache.rb +231 -0
- data/lib/igniter/replication/bootstrapper.rb +61 -0
- data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
- data/lib/igniter/replication/bootstrappers/git.rb +39 -0
- data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
- data/lib/igniter/replication/expansion_plan.rb +38 -0
- data/lib/igniter/replication/expansion_planner.rb +142 -0
- data/lib/igniter/replication/manifest.rb +45 -0
- data/lib/igniter/replication/network_topology.rb +123 -0
- data/lib/igniter/replication/node_role.rb +42 -0
- data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
- data/lib/igniter/replication/replication_agent.rb +87 -0
- data/lib/igniter/replication/role_registry.rb +73 -0
- data/lib/igniter/replication/ssh_session.rb +77 -0
- data/lib/igniter/replication.rb +54 -0
- data/lib/igniter/runtime/execution.rb +18 -0
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/resolver.rb +254 -16
- data/lib/igniter/runtime/stores/redis_store.rb +41 -4
- data/lib/igniter/server/client.rb +44 -1
- data/lib/igniter/server/config.rb +13 -6
- data/lib/igniter/server/handlers/event_handler.rb +4 -0
- data/lib/igniter/server/handlers/execute_handler.rb +6 -0
- data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
- data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
- data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
- data/lib/igniter/server/handlers/peers_handler.rb +115 -0
- data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
- data/lib/igniter/server/http_server.rb +54 -17
- data/lib/igniter/server/router.rb +54 -21
- data/lib/igniter/server/server_logger.rb +52 -0
- data/lib/igniter/server.rb +6 -0
- data/lib/igniter/skill/feedback.rb +116 -0
- data/lib/igniter/skill/output_schema.rb +110 -0
- data/lib/igniter/skill.rb +218 -0
- data/lib/igniter/temporal.rb +84 -0
- data/lib/igniter/tool/discoverable.rb +151 -0
- data/lib/igniter/tool.rb +52 -0
- data/lib/igniter/tool_registry.rb +144 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +17 -0
- metadata +122 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Replication
|
|
5
|
+
# Thread-safe in-memory registry of known nodes in the deployment network.
|
|
6
|
+
#
|
|
7
|
+
# Updated by ReflectiveReplicationAgent as nodes are spawned, heartbeat-ed,
|
|
8
|
+
# or removed. Can be shared across agent handler invocations via state.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# topology = NetworkTopology.new
|
|
12
|
+
# topology.register(node_id: "abc", host: "10.0.0.2", role: :worker)
|
|
13
|
+
# topology.nodes(role: :worker) # => [NodeEntry]
|
|
14
|
+
# topology.needs_role?(:coordinator) # => true
|
|
15
|
+
class NetworkTopology
|
|
16
|
+
# Mutable record for a single live node (mutated only inside the Mutex).
|
|
17
|
+
NodeEntry = Struct.new(:node_id, :host, :role,
|
|
18
|
+
:registered_at, :last_seen_at, :healthy,
|
|
19
|
+
keyword_init: true)
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@nodes = {}
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register or overwrite a node entry.
|
|
27
|
+
#
|
|
28
|
+
# @param node_id [String]
|
|
29
|
+
# @param host [String]
|
|
30
|
+
# @param role [Symbol, nil]
|
|
31
|
+
# @return [NodeEntry]
|
|
32
|
+
def register(node_id:, host:, role: nil)
|
|
33
|
+
now = Time.now
|
|
34
|
+
entry = NodeEntry.new(
|
|
35
|
+
node_id: node_id,
|
|
36
|
+
host: host,
|
|
37
|
+
role: role&.to_sym,
|
|
38
|
+
registered_at: now,
|
|
39
|
+
last_seen_at: now,
|
|
40
|
+
healthy: true
|
|
41
|
+
)
|
|
42
|
+
@mutex.synchronize { @nodes[node_id] = entry }
|
|
43
|
+
entry
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Update last_seen_at for a known node (heartbeat).
|
|
47
|
+
#
|
|
48
|
+
# @param node_id [String]
|
|
49
|
+
# @return [Boolean] true if the node was found
|
|
50
|
+
def touch(node_id:)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
entry = @nodes[node_id]
|
|
53
|
+
return false unless entry
|
|
54
|
+
|
|
55
|
+
entry.last_seen_at = Time.now
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Mark a node as unhealthy (e.g. SSH unreachable).
|
|
61
|
+
#
|
|
62
|
+
# @param node_id [String]
|
|
63
|
+
# @return [Boolean] true if the node was found
|
|
64
|
+
def mark_unhealthy(node_id:)
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
entry = @nodes[node_id]
|
|
67
|
+
return false unless entry
|
|
68
|
+
|
|
69
|
+
entry.healthy = false
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Remove a node from the topology.
|
|
75
|
+
#
|
|
76
|
+
# @param node_id [String]
|
|
77
|
+
# @return [NodeEntry, nil] the removed entry, or nil if not found
|
|
78
|
+
def remove(node_id:)
|
|
79
|
+
@mutex.synchronize { @nodes.delete(node_id) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Return nodes, optionally filtered by role.
|
|
83
|
+
#
|
|
84
|
+
# @param role [Symbol, nil]
|
|
85
|
+
# @return [Array<NodeEntry>]
|
|
86
|
+
def nodes(role: nil)
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
entries = @nodes.values.dup
|
|
89
|
+
role ? entries.select { |e| e.role == role.to_sym } : entries
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# True when no healthy node with the given role exists.
|
|
94
|
+
#
|
|
95
|
+
# @param role [Symbol, String]
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def needs_role?(role)
|
|
98
|
+
nodes(role: role).none?(&:healthy)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Count of healthy nodes across all roles.
|
|
102
|
+
#
|
|
103
|
+
# @return [Integer]
|
|
104
|
+
def healthy_count
|
|
105
|
+
@mutex.synchronize { @nodes.values.count(&:healthy) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Total number of registered nodes.
|
|
109
|
+
#
|
|
110
|
+
# @return [Integer]
|
|
111
|
+
def size
|
|
112
|
+
@mutex.synchronize { @nodes.size }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# All registered node IDs.
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<String>]
|
|
118
|
+
def node_ids
|
|
119
|
+
@mutex.synchronize { @nodes.keys.dup }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Replication
|
|
5
|
+
# Immutable description of a specialised role a differentiated node can assume.
|
|
6
|
+
#
|
|
7
|
+
# When a node replicates with differentiation it carries a NodeRole that
|
|
8
|
+
# shapes its remote configuration: which contracts to activate, which env
|
|
9
|
+
# vars to inject, and which capability tags to advertise in the mesh.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# role = NodeRole.new(
|
|
13
|
+
# name: :worker,
|
|
14
|
+
# contracts: ["ComputeContract"],
|
|
15
|
+
# capabilities: [:compute],
|
|
16
|
+
# env_overrides: { "WORKER_POOL" => "8" },
|
|
17
|
+
# tags: [:cpu_heavy]
|
|
18
|
+
# )
|
|
19
|
+
class NodeRole
|
|
20
|
+
attr_reader :name, :contracts, :capabilities, :env_overrides, :tags
|
|
21
|
+
|
|
22
|
+
def initialize(name:, contracts: [], capabilities: [], env_overrides: {}, tags: [])
|
|
23
|
+
@name = name.to_sym
|
|
24
|
+
@contracts = Array(contracts).map(&:to_s).freeze
|
|
25
|
+
@capabilities = Array(capabilities).map(&:to_sym).freeze
|
|
26
|
+
@env_overrides = Hash(env_overrides).transform_keys(&:to_s).freeze
|
|
27
|
+
@tags = Array(tags).map(&:to_sym).freeze
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
name: @name,
|
|
34
|
+
contracts: @contracts,
|
|
35
|
+
capabilities: @capabilities,
|
|
36
|
+
env_overrides: @env_overrides,
|
|
37
|
+
tags: @tags
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "replication_agent"
|
|
5
|
+
require_relative "network_topology"
|
|
6
|
+
require_relative "expansion_plan"
|
|
7
|
+
require_relative "expansion_planner"
|
|
8
|
+
require_relative "role_registry"
|
|
9
|
+
|
|
10
|
+
module Igniter
|
|
11
|
+
module Replication
|
|
12
|
+
# ReplicationAgent extended with episodic memory, self-reflection, and
|
|
13
|
+
# topology-aware network expansion.
|
|
14
|
+
#
|
|
15
|
+
# == Additional message types
|
|
16
|
+
#
|
|
17
|
+
# :assess_network — run ExpansionPlanner; execute replicate_role/retire_node actions
|
|
18
|
+
# :reflect — run a ReflectionCycle over recent episodes; store summary in state
|
|
19
|
+
# :register_node — register a remote node in the local NetworkTopology
|
|
20
|
+
# :node_heartbeat — update last_seen_at for a known node
|
|
21
|
+
# :signal_scale — emit a :scale_signal episode (e.g. from load monitors)
|
|
22
|
+
#
|
|
23
|
+
# == State keys (in addition to inherited :events)
|
|
24
|
+
#
|
|
25
|
+
# :topology — NetworkTopology instance (created lazily on first access)
|
|
26
|
+
# :host_pool — Array<String> of candidate hosts
|
|
27
|
+
# :required_roles — Array<Symbol> of roles that must always be present
|
|
28
|
+
# :last_plan — Hash from the most recent ExpansionPlan
|
|
29
|
+
# :last_reflection — String summary from the most recent reflection cycle
|
|
30
|
+
#
|
|
31
|
+
# == Memory
|
|
32
|
+
#
|
|
33
|
+
# Call +enable_class_memory+ in the class body to activate episodic memory.
|
|
34
|
+
# Memory is class-level (shared across handler invocations on this class).
|
|
35
|
+
#
|
|
36
|
+
# == Auto-assessment
|
|
37
|
+
#
|
|
38
|
+
# Call +auto_assess(every: N)+ to schedule periodic topology assessment.
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# RoleRegistry.define(:worker, env_overrides: { "POOL" => "4" })
|
|
42
|
+
#
|
|
43
|
+
# class MyAgent < ReflectiveReplicationAgent
|
|
44
|
+
# enable_class_memory
|
|
45
|
+
# auto_assess every: 60
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# ref = MyAgent.start(initial_state: {
|
|
49
|
+
# topology: NetworkTopology.new,
|
|
50
|
+
# required_roles: [:worker],
|
|
51
|
+
# host_pool: ["10.0.0.2", "10.0.0.3"]
|
|
52
|
+
# })
|
|
53
|
+
# ref.call(:assess_network)
|
|
54
|
+
class ReflectiveReplicationAgent < ReplicationAgent
|
|
55
|
+
initial_state topology: nil, host_pool: [], required_roles: [],
|
|
56
|
+
last_plan: nil, last_reflection: nil
|
|
57
|
+
|
|
58
|
+
# ── Class-level memory ─────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
# Activate episodic memory for this class.
|
|
62
|
+
#
|
|
63
|
+
# @param store [Memory::Store, nil] backing store; defaults to global default
|
|
64
|
+
# @return [void]
|
|
65
|
+
def enable_class_memory(store: nil)
|
|
66
|
+
require "igniter/memory"
|
|
67
|
+
@class_memory_store = store || Igniter::Memory.default_store
|
|
68
|
+
@class_memory_enabled = true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns true when class-level memory has been activated.
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def class_memory_enabled?
|
|
75
|
+
@class_memory_enabled || false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the AgentMemory facade bound to this class, or nil when disabled.
|
|
79
|
+
#
|
|
80
|
+
# @return [Memory::AgentMemory, nil]
|
|
81
|
+
def class_memory
|
|
82
|
+
return nil unless class_memory_enabled?
|
|
83
|
+
|
|
84
|
+
@class_memory ||= Igniter::Memory::AgentMemory.new(
|
|
85
|
+
store: @class_memory_store,
|
|
86
|
+
agent_id: name.to_s
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Reset class-level memory state. Intended for use in tests.
|
|
91
|
+
#
|
|
92
|
+
# @return [void]
|
|
93
|
+
def reset_class_memory!
|
|
94
|
+
@class_memory = nil
|
|
95
|
+
@class_memory_store = nil
|
|
96
|
+
@class_memory_enabled = false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Register a recurring topology assessment.
|
|
100
|
+
#
|
|
101
|
+
# @param every [Numeric] interval in seconds
|
|
102
|
+
# @return [void]
|
|
103
|
+
def auto_assess(every:)
|
|
104
|
+
schedule(:auto_assessment, every: every) do |state:|
|
|
105
|
+
agent = new
|
|
106
|
+
agent.send(:run_assess_network, state, {})
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ── deliver: intercept lifecycle events into memory ────────────────────────
|
|
112
|
+
|
|
113
|
+
# Override the no-op deliver from ReplicationAgent to record events.
|
|
114
|
+
# Subclasses can call +super+ and then add their own routing.
|
|
115
|
+
#
|
|
116
|
+
# @param type [Symbol]
|
|
117
|
+
# @param payload [Hash]
|
|
118
|
+
def deliver(type, payload = {})
|
|
119
|
+
self.class.class_memory&.record(
|
|
120
|
+
type: :replication_event,
|
|
121
|
+
content: "#{type}: #{payload.inspect}",
|
|
122
|
+
outcome: type == :replication_failed ? "failure" : "success",
|
|
123
|
+
importance: 0.6
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ── Handlers ───────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
# Re-define :replicate (parent's handler is cleared by Agent.inherited).
|
|
130
|
+
on :replicate do |state:, payload:, **|
|
|
131
|
+
agent = new
|
|
132
|
+
agent.send(:run_replicate, payload)
|
|
133
|
+
state
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
on :assess_network do |state:, payload:, **|
|
|
137
|
+
agent = new
|
|
138
|
+
agent.send(:run_assess_network, state, payload)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
on :reflect do |state:, payload:, **|
|
|
142
|
+
next state unless class_memory_enabled?
|
|
143
|
+
|
|
144
|
+
rec = class_memory.reflect
|
|
145
|
+
class_memory.record(
|
|
146
|
+
type: :reflection,
|
|
147
|
+
content: rec.summary,
|
|
148
|
+
outcome: "success",
|
|
149
|
+
importance: 0.8
|
|
150
|
+
)
|
|
151
|
+
state.merge(last_reflection: rec.summary)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
on :register_node do |state:, payload:, **|
|
|
155
|
+
topology = state[:topology] || NetworkTopology.new
|
|
156
|
+
topology.register(
|
|
157
|
+
node_id: payload.fetch(:node_id),
|
|
158
|
+
host: payload.fetch(:host),
|
|
159
|
+
role: payload[:role]
|
|
160
|
+
)
|
|
161
|
+
state.merge(topology: topology)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
on :node_heartbeat do |state:, payload:, **|
|
|
165
|
+
state[:topology]&.touch(node_id: payload.fetch(:node_id))
|
|
166
|
+
state
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
on :signal_scale do |state:, payload:, **|
|
|
170
|
+
role = payload.fetch(:role)
|
|
171
|
+
class_memory&.record(
|
|
172
|
+
type: :scale_signal,
|
|
173
|
+
content: "scale_out:#{role}",
|
|
174
|
+
outcome: nil
|
|
175
|
+
)
|
|
176
|
+
state
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
# Assess the network topology and execute the resulting plan.
|
|
182
|
+
# Returns the updated state hash.
|
|
183
|
+
#
|
|
184
|
+
# @param state [Hash]
|
|
185
|
+
# @param payload [Hash]
|
|
186
|
+
# @return [Hash]
|
|
187
|
+
def run_assess_network(state, payload)
|
|
188
|
+
topology = state[:topology] || NetworkTopology.new
|
|
189
|
+
planner = ExpansionPlanner.new(
|
|
190
|
+
topology: topology,
|
|
191
|
+
memory: self.class.class_memory,
|
|
192
|
+
required_roles: Array(payload[:required_roles] || state[:required_roles]),
|
|
193
|
+
host_pool: Array(payload[:host_pool] || state[:host_pool])
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
plan = planner.plan
|
|
197
|
+
|
|
198
|
+
plan.actions.each do |action|
|
|
199
|
+
case action[:action]
|
|
200
|
+
when :replicate_role
|
|
201
|
+
run_replicate_role(action, topology)
|
|
202
|
+
when :retire_node
|
|
203
|
+
topology.remove(node_id: action[:node_id])
|
|
204
|
+
deliver(:node_retired, node_id: action[:node_id], host: action[:host])
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
self.class.class_memory&.record(
|
|
209
|
+
type: :assessment,
|
|
210
|
+
content: plan.rationale.to_s,
|
|
211
|
+
outcome: "success"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
state.merge(topology: topology, last_plan: plan.to_h)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Execute a :replicate_role action: call run_replicate + register in topology.
|
|
218
|
+
#
|
|
219
|
+
# @param action [Hash]
|
|
220
|
+
# @param topology [NetworkTopology]
|
|
221
|
+
def run_replicate_role(action, topology)
|
|
222
|
+
role_obj = RoleRegistry.registered?(action[:role]) ? RoleRegistry.fetch(action[:role]) : nil
|
|
223
|
+
env = role_obj&.env_overrides || {}
|
|
224
|
+
|
|
225
|
+
run_replicate(
|
|
226
|
+
host: action.fetch(:host),
|
|
227
|
+
user: action.fetch(:user, "deploy"),
|
|
228
|
+
strategy: action.fetch(:strategy, :git),
|
|
229
|
+
env: env,
|
|
230
|
+
bootstrapper_options: action.fetch(:bootstrapper_options, {})
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
topology.register(node_id: SecureRandom.uuid, host: action[:host], role: action[:role])
|
|
234
|
+
deliver(:role_replicated, host: action[:host], role: action[:role])
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../agent"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Replication
|
|
7
|
+
# Agent that handles :replicate messages to deploy Igniter to remote servers.
|
|
8
|
+
#
|
|
9
|
+
# In production, start via ReplicationAgent.start and send messages through
|
|
10
|
+
# the Ref. In tests, instantiate directly and call handle_message/1.
|
|
11
|
+
#
|
|
12
|
+
# Message payload keys:
|
|
13
|
+
# host: [String] (required) remote hostname or IP
|
|
14
|
+
# user: [String] (required) SSH username
|
|
15
|
+
# key: [String] path to SSH private key (optional)
|
|
16
|
+
# port: [Integer] SSH port (default: 22)
|
|
17
|
+
# env: [Hash] environment variables for remote (default: {})
|
|
18
|
+
# strategy: [Symbol] :git, :gem, or :tarball (default: :git)
|
|
19
|
+
# target_path: [String] installation path on remote (default: /opt/igniter)
|
|
20
|
+
# bootstrapper_options: [Hash] forwarded to the bootstrapper constructor
|
|
21
|
+
#
|
|
22
|
+
class ReplicationAgent < Igniter::Agent
|
|
23
|
+
MAX_REPLICAS = 10
|
|
24
|
+
|
|
25
|
+
initial_state events: []
|
|
26
|
+
|
|
27
|
+
# Class-level handler for the agent mailbox runtime.
|
|
28
|
+
# Instantiates a temporary agent to run the replication so that
|
|
29
|
+
# deliver/1 can be overridden in subclasses.
|
|
30
|
+
on :replicate do |state:, payload:, **|
|
|
31
|
+
agent = new
|
|
32
|
+
agent.send(:run_replicate, payload)
|
|
33
|
+
state
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Emit a named lifecycle event. Override or stub in tests.
|
|
37
|
+
#
|
|
38
|
+
# @param type [Symbol] event name (e.g. :replication_started)
|
|
39
|
+
# @param payload [Hash] associated data
|
|
40
|
+
def deliver(type, payload = {})
|
|
41
|
+
# Base implementation: no-op. Override in subclasses for real routing.
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Process a raw message hash synchronously (used in tests and internal tooling).
|
|
45
|
+
#
|
|
46
|
+
# @param message [Hash] must have :type key; optional :payload key
|
|
47
|
+
def handle_message(message)
|
|
48
|
+
type = message.fetch(:type).to_sym
|
|
49
|
+
payload = message.fetch(:payload, {})
|
|
50
|
+
return unless type == :replicate
|
|
51
|
+
|
|
52
|
+
run_replicate(payload)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run_replicate(payload) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
58
|
+
host = payload[:host] || raise(ArgumentError, "host is required")
|
|
59
|
+
user = payload[:user] || raise(ArgumentError, "user is required")
|
|
60
|
+
key = payload[:key]
|
|
61
|
+
port = payload.fetch(:port, 22)
|
|
62
|
+
env = payload.fetch(:env, {})
|
|
63
|
+
strategy = payload.fetch(:strategy, :git).to_sym
|
|
64
|
+
target_path = payload.fetch(:target_path, "/opt/igniter")
|
|
65
|
+
bs_options = payload.fetch(:bootstrapper_options, {})
|
|
66
|
+
|
|
67
|
+
session = SSHSession.new(host: host, user: user, key: key, port: port)
|
|
68
|
+
bootstrapper = Replication.bootstrapper_for(strategy, **bs_options)
|
|
69
|
+
manifest = Manifest.current
|
|
70
|
+
|
|
71
|
+
deliver(:replication_started, host: host, instance_id: manifest.instance_id)
|
|
72
|
+
|
|
73
|
+
bootstrapper.install(session: session, manifest: manifest,
|
|
74
|
+
env: env, target_path: target_path)
|
|
75
|
+
bootstrapper.start(session: session, manifest: manifest, target_path: target_path)
|
|
76
|
+
verified = bootstrapper.verify(session: session, target_path: target_path)
|
|
77
|
+
|
|
78
|
+
deliver(:replication_completed,
|
|
79
|
+
host: host, instance_id: manifest.instance_id, verified: verified)
|
|
80
|
+
rescue SSHSession::SSHError => e
|
|
81
|
+
deliver(:replication_failed, host: host, error: e.message)
|
|
82
|
+
rescue ArgumentError => e
|
|
83
|
+
deliver(:replication_failed, host: payload[:host], error: e.message)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Replication
|
|
5
|
+
# Module-level registry of named NodeRoles.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# RoleRegistry.define(:worker,
|
|
9
|
+
# contracts: ["ComputeContract"],
|
|
10
|
+
# capabilities: [:compute],
|
|
11
|
+
# env_overrides: { "WORKER_POOL" => "4" }
|
|
12
|
+
# )
|
|
13
|
+
#
|
|
14
|
+
# role = RoleRegistry.fetch(:worker)
|
|
15
|
+
# role.env_overrides # => { "WORKER_POOL" => "4" }
|
|
16
|
+
module RoleRegistry
|
|
17
|
+
@roles = {}
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Define and register a new role.
|
|
21
|
+
#
|
|
22
|
+
# @param name [Symbol, String]
|
|
23
|
+
# @param contracts [Array<String>]
|
|
24
|
+
# @param capabilities [Array<Symbol>]
|
|
25
|
+
# @param env_overrides [Hash]
|
|
26
|
+
# @param tags [Array<Symbol>]
|
|
27
|
+
# @return [NodeRole]
|
|
28
|
+
def define(name, contracts: [], capabilities: [], env_overrides: {}, tags: [])
|
|
29
|
+
role = NodeRole.new(
|
|
30
|
+
name: name,
|
|
31
|
+
contracts: contracts,
|
|
32
|
+
capabilities: capabilities,
|
|
33
|
+
env_overrides: env_overrides,
|
|
34
|
+
tags: tags
|
|
35
|
+
)
|
|
36
|
+
@roles[role.name] = role
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fetch a role by name.
|
|
40
|
+
#
|
|
41
|
+
# @param name [Symbol, String]
|
|
42
|
+
# @return [NodeRole]
|
|
43
|
+
# @raise [ArgumentError] if not registered
|
|
44
|
+
def fetch(name)
|
|
45
|
+
@roles.fetch(name.to_sym) do
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"Unknown role: #{name}. Available: #{@roles.keys.join(", ")}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns true if a role with the given name is registered.
|
|
52
|
+
#
|
|
53
|
+
# @param name [Symbol, String]
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def registered?(name)
|
|
56
|
+
@roles.key?(name.to_sym)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# All registered roles (copy to prevent external mutation).
|
|
60
|
+
#
|
|
61
|
+
# @return [Hash{Symbol => NodeRole}]
|
|
62
|
+
def all
|
|
63
|
+
@roles.dup
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Remove all registrations. Useful in tests.
|
|
67
|
+
def reset!
|
|
68
|
+
@roles = {}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Replication
|
|
7
|
+
# Thin subprocess wrapper over the +ssh+ and +scp+ CLI tools.
|
|
8
|
+
#
|
|
9
|
+
# Provides exec/exec! for running remote commands and upload! for
|
|
10
|
+
# copying local files to the remote host. No external gems required.
|
|
11
|
+
class SSHSession
|
|
12
|
+
class SSHError < Igniter::Error; end
|
|
13
|
+
|
|
14
|
+
DEFAULT_CONNECT_TIMEOUT = 10
|
|
15
|
+
|
|
16
|
+
def initialize(host:, user:, key: nil, port: 22, connect_timeout: DEFAULT_CONNECT_TIMEOUT)
|
|
17
|
+
@host = host
|
|
18
|
+
@user = user
|
|
19
|
+
@key = key
|
|
20
|
+
@port = port
|
|
21
|
+
@connect_timeout = connect_timeout
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Run a command on the remote host. Raises SSHError on non-zero exit.
|
|
25
|
+
# Returns stdout string on success.
|
|
26
|
+
def exec!(command)
|
|
27
|
+
result = exec(command)
|
|
28
|
+
raise SSHError, "SSH command failed on #{@host}: #{command.inspect}\n#{result[:stderr]}" \
|
|
29
|
+
unless result[:success]
|
|
30
|
+
|
|
31
|
+
result[:stdout]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Run a command on the remote host.
|
|
35
|
+
# Returns a Hash: { stdout:, stderr:, success:, exit_code: }
|
|
36
|
+
def exec(command)
|
|
37
|
+
stdout, stderr, status = Open3.capture3(*build_cmd(command))
|
|
38
|
+
{ stdout: stdout, stderr: stderr, success: status.success?, exit_code: status.exitstatus }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Upload a local file to the remote host via scp.
|
|
42
|
+
# Raises SSHError on failure.
|
|
43
|
+
def upload!(local_path, remote_path)
|
|
44
|
+
args = ["scp", *scp_opts, "-P", @port.to_s, local_path, "#{@user}@#{@host}:#{remote_path}"]
|
|
45
|
+
_, stderr, status = Open3.capture3(*args)
|
|
46
|
+
raise SSHError, "SCP upload failed to #{@host}: #{stderr}" unless status.success?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Quick connectivity test. Returns true if the remote responds.
|
|
50
|
+
def test_connection
|
|
51
|
+
exec("echo ok")[:success]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_cmd(command)
|
|
57
|
+
["ssh", *ssh_opts, "-p", @port.to_s, "#{@user}@#{@host}", command]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ssh_opts
|
|
61
|
+
opts = [
|
|
62
|
+
"-o", "StrictHostKeyChecking=no",
|
|
63
|
+
"-o", "BatchMode=yes",
|
|
64
|
+
"-o", "ConnectTimeout=#{@connect_timeout}"
|
|
65
|
+
]
|
|
66
|
+
opts += ["-i", @key] if @key
|
|
67
|
+
opts
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scp_opts
|
|
71
|
+
opts = ["-o", "StrictHostKeyChecking=no", "-B"]
|
|
72
|
+
opts += ["-i", @key] if @key
|
|
73
|
+
opts
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "replication/manifest"
|
|
4
|
+
require_relative "replication/ssh_session"
|
|
5
|
+
require_relative "replication/bootstrapper"
|
|
6
|
+
require_relative "replication/bootstrappers/git"
|
|
7
|
+
require_relative "replication/bootstrappers/gem"
|
|
8
|
+
require_relative "replication/bootstrappers/tarball"
|
|
9
|
+
require_relative "replication/replication_agent"
|
|
10
|
+
require_relative "replication/node_role"
|
|
11
|
+
require_relative "replication/role_registry"
|
|
12
|
+
require_relative "replication/network_topology"
|
|
13
|
+
require_relative "replication/expansion_plan"
|
|
14
|
+
require_relative "replication/expansion_planner"
|
|
15
|
+
require_relative "replication/reflective_replication_agent"
|
|
16
|
+
|
|
17
|
+
module Igniter
|
|
18
|
+
# Self-replication capability: deploy a running Igniter instance to a
|
|
19
|
+
# remote server via SSH using one of three deployment strategies.
|
|
20
|
+
#
|
|
21
|
+
# Usage:
|
|
22
|
+
# require "igniter/replication"
|
|
23
|
+
# ref = Igniter::Replication::ReplicationAgent.start
|
|
24
|
+
# ref.send(:replicate,
|
|
25
|
+
# host: "10.0.0.2",
|
|
26
|
+
# user: "deploy",
|
|
27
|
+
# strategy: :git,
|
|
28
|
+
# bootstrapper_options: { repo_url: "https://github.com/org/app" }
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
module Replication
|
|
32
|
+
ReplicationError = Class.new(Igniter::Error)
|
|
33
|
+
|
|
34
|
+
BOOTSTRAPPERS = {
|
|
35
|
+
git: Bootstrappers::Git,
|
|
36
|
+
gem: Bootstrappers::Gem,
|
|
37
|
+
tarball: Bootstrappers::Tarball
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Instantiate the bootstrapper for the given strategy.
|
|
41
|
+
#
|
|
42
|
+
# @param strategy [Symbol] one of :git, :gem, :tarball
|
|
43
|
+
# @param options [Hash] forwarded to the bootstrapper constructor
|
|
44
|
+
# @return [Bootstrapper]
|
|
45
|
+
# @raise [ArgumentError] for unknown strategies
|
|
46
|
+
def self.bootstrapper_for(strategy, **options)
|
|
47
|
+
klass = BOOTSTRAPPERS.fetch(strategy.to_sym) do
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"Unknown bootstrapper: #{strategy}. Available: #{BOOTSTRAPPERS.keys.join(", ")}"
|
|
50
|
+
end
|
|
51
|
+
klass.new(**options)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|