Skip to content

Commit 3014121

Browse files
committed
✨ feat(fireteact): add graceful shutdown and registry cache support
- Add graceful shutdown with Gitea runner unregistration on SIGTERM/SIGINT - Clean up stale .sock/.log files from previous runs on startup - Fix Gitea API response parsing for runners endpoint (wrapped response + label objects) - Add cloud-init parity with fireactions: Zot registry mirrors, Docker daemon, BuildKit config - Fix dnsmasq per-subnet DHCP options using tag: directive - Add debug logging for Gitea API troubleshooting
1 parent 4dab023 commit 3014121

7 files changed

Lines changed: 443 additions & 27 deletions

File tree

fireteact/internal/firecracker/manager.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,87 @@ func NewManager(cfg *config.Config, log *logrus.Logger) (*Manager, error) {
9595
return nil, fmt.Errorf("failed to create pool directory: %w", err)
9696
}
9797

98+
// Clean up stale resources from previous runs
99+
m.cleanupStaleResources()
100+
98101
return m, nil
99102
}
100103

104+
// cleanupStaleResources removes orphaned socket and log files from previous runs.
105+
// This is called on startup to clean up after unclean shutdowns.
106+
func (m *Manager) cleanupStaleResources() {
107+
// Scan all pool directories
108+
entries, err := os.ReadDir(DefaultPoolDir)
109+
if err != nil {
110+
m.log.Warnf("Failed to read pool directory %s: %v", DefaultPoolDir, err)
111+
return
112+
}
113+
114+
for _, entry := range entries {
115+
if !entry.IsDir() {
116+
continue
117+
}
118+
119+
poolDir := filepath.Join(DefaultPoolDir, entry.Name())
120+
m.cleanupPoolDirectory(poolDir)
121+
}
122+
}
123+
124+
// cleanupPoolDirectory removes stale socket and log files from a pool directory.
125+
func (m *Manager) cleanupPoolDirectory(poolDir string) {
126+
files, err := os.ReadDir(poolDir)
127+
if err != nil {
128+
m.log.Warnf("Failed to read pool directory %s: %v", poolDir, err)
129+
return
130+
}
131+
132+
for _, file := range files {
133+
if file.IsDir() {
134+
continue
135+
}
136+
137+
// Only check socket files
138+
if filepath.Ext(file.Name()) != ".sock" {
139+
continue
140+
}
141+
142+
socketPath := filepath.Join(poolDir, file.Name())
143+
144+
// Try to connect to the socket to check if firecracker is still running
145+
if m.isSocketActive(socketPath) {
146+
m.log.Debugf("Socket %s is still active, skipping cleanup", socketPath)
147+
continue
148+
}
149+
150+
// Socket is stale, remove it and the corresponding log file
151+
m.log.Infof("Removing stale socket: %s", socketPath)
152+
if err := os.Remove(socketPath); err != nil {
153+
m.log.Warnf("Failed to remove stale socket %s: %v", socketPath, err)
154+
}
155+
156+
// Also remove corresponding log file
157+
logPath := socketPath[:len(socketPath)-5] + ".log" // Replace .sock with .log
158+
if _, err := os.Stat(logPath); err == nil {
159+
m.log.Infof("Removing stale log: %s", logPath)
160+
if err := os.Remove(logPath); err != nil {
161+
m.log.Warnf("Failed to remove stale log %s: %v", logPath, err)
162+
}
163+
}
164+
}
165+
}
166+
167+
// isSocketActive checks if a socket file has an active firecracker process.
168+
func (m *Manager) isSocketActive(socketPath string) bool {
169+
// Try to connect to the socket with a short timeout
170+
conn, err := net.DialTimeout("unix", socketPath, 100*time.Millisecond)
171+
if err != nil {
172+
// Connection failed - socket is stale
173+
return false
174+
}
175+
conn.Close()
176+
return true
177+
}
178+
101179
// Close closes the manager and releases resources.
102180
func (m *Manager) Close() error {
103181
if m.containerd != nil {

fireteact/internal/gitea/client.go

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,23 @@ type RegistrationToken struct {
3030
Token string `json:"token"`
3131
}
3232

33+
// RunnerLabel represents a label attached to a runner in Gitea.
34+
type RunnerLabel struct {
35+
ID int64 `json:"id"`
36+
Name string `json:"name"`
37+
Type string `json:"type"`
38+
}
39+
3340
// Runner represents a registered runner in Gitea.
3441
type Runner struct {
35-
ID int64 `json:"id"`
36-
Name string `json:"name"`
37-
Status string `json:"status"`
38-
Busy bool `json:"busy"`
39-
Labels []string `json:"labels"`
40-
Version string `json:"version"`
41-
LastContact string `json:"last_contact,omitempty"`
42+
ID int64 `json:"id"`
43+
Name string `json:"name"`
44+
Status string `json:"status"`
45+
Busy bool `json:"busy"`
46+
Ephemeral bool `json:"ephemeral"`
47+
Labels []RunnerLabel `json:"labels"`
48+
Version string `json:"version,omitempty"`
49+
LastContact string `json:"last_contact,omitempty"`
4250
}
4351

4452
// Job represents a Gitea Actions job.
@@ -153,6 +161,12 @@ func (c *Client) DeleteRunner(ctx context.Context, runnerID int64) error {
153161
return nil
154162
}
155163

164+
// runnersResponse wraps the Gitea API response which contains runners and pagination info.
165+
type runnersResponse struct {
166+
Runners []Runner `json:"runners"`
167+
TotalCount int `json:"total_count"`
168+
}
169+
156170
// ListRunners returns all runners registered with Gitea.
157171
func (c *Client) ListRunners(ctx context.Context) ([]Runner, error) {
158172
endpoint := c.getRunnersListEndpoint()
@@ -182,19 +196,82 @@ func (c *Client) ListRunners(ctx context.Context) ([]Runner, error) {
182196
return nil, fmt.Errorf("failed to list runners: status %d, body: %s", resp.StatusCode, string(body))
183197
}
184198

199+
// Log raw response for debugging
200+
c.log.WithField("response_body", string(body)).Debug("Raw Gitea runners response")
201+
202+
// Try parsing as direct array first (older Gitea versions)
185203
var runners []Runner
186-
if err := json.Unmarshal(body, &runners); err != nil {
187-
return nil, fmt.Errorf("failed to parse runners response: %w", err)
204+
if err := json.Unmarshal(body, &runners); err == nil {
205+
c.log.WithField("runner_count", len(runners)).Debug("Parsed as direct array")
206+
return runners, nil
188207
}
189208

190-
return runners, nil
209+
// Try parsing as wrapped response (newer Gitea versions with pagination)
210+
var wrappedResponse runnersResponse
211+
if err := json.Unmarshal(body, &wrappedResponse); err != nil {
212+
c.log.WithField("response_body", string(body)).Error("Failed to parse runners response")
213+
return nil, fmt.Errorf("failed to parse runners response: %w (body: %.200s)", err, string(body))
214+
}
215+
216+
c.log.WithField("runner_count", len(wrappedResponse.Runners)).Debug("Parsed as wrapped response")
217+
return wrappedResponse.Runners, nil
191218
}
192219

193220
// GetInstanceURL returns the Gitea instance URL.
194221
func (c *Client) GetInstanceURL() string {
195222
return c.instanceURL
196223
}
197224

225+
// DeleteRunnerByName finds and deletes a runner by its name.
226+
// This is useful during graceful shutdown when we only have the runner name.
227+
// Returns nil if the runner is not found (already deleted/never registered).
228+
func (c *Client) DeleteRunnerByName(ctx context.Context, name string) error {
229+
runners, err := c.ListRunners(ctx)
230+
if err != nil {
231+
return fmt.Errorf("failed to list runners: %w", err)
232+
}
233+
234+
c.log.WithFields(logrus.Fields{
235+
"target_name": name,
236+
"runner_count": len(runners),
237+
}).Info("Searching for runner to delete")
238+
239+
for _, runner := range runners {
240+
c.log.WithFields(logrus.Fields{
241+
"gitea_name": runner.Name,
242+
"target": name,
243+
"runner_id": runner.ID,
244+
}).Debug("Comparing runner names")
245+
246+
if runner.Name == name {
247+
c.log.WithFields(logrus.Fields{
248+
"runner_name": name,
249+
"runner_id": runner.ID,
250+
}).Info("Found runner, deleting from Gitea")
251+
if err := c.DeleteRunner(ctx, runner.ID); err != nil {
252+
return err
253+
}
254+
c.log.WithFields(logrus.Fields{
255+
"runner_name": name,
256+
"runner_id": runner.ID,
257+
}).Info("Successfully deleted runner from Gitea")
258+
return nil
259+
}
260+
}
261+
262+
// Runner not found - log the names we did find for debugging
263+
var foundNames []string
264+
for _, r := range runners {
265+
foundNames = append(foundNames, r.Name)
266+
}
267+
c.log.WithFields(logrus.Fields{
268+
"target_name": name,
269+
"found_names": foundNames,
270+
"runner_count": len(runners),
271+
}).Warn("Runner not found in Gitea runner list")
272+
return nil
273+
}
274+
198275
// GetPendingJobs retrieves pending jobs that match the given labels.
199276
// Note: This is a placeholder - the actual implementation depends on Gitea's API.
200277
func (c *Client) GetPendingJobs(ctx context.Context, labels []string) ([]Job, error) {

fireteact/internal/pool/pool.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ func (p *Pool) Start(ctx context.Context) error {
134134
}
135135

136136
// Stop gracefully stops the pool and all runners.
137+
// This includes unregistering runners from Gitea and destroying VMs.
137138
func (p *Pool) Stop() error {
138139
p.cancel()
139140
if p.scaleTicker != nil {
@@ -145,7 +146,22 @@ func (p *Pool) Stop() error {
145146
p.mu.Lock()
146147
defer p.mu.Unlock()
147148

149+
// Create a context with timeout for shutdown operations
150+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
151+
defer cancel()
152+
148153
for id, runner := range p.runners {
154+
// First, try to unregister the runner from Gitea
155+
// This prevents the runner from showing as offline in Gitea UI
156+
if runner.Name != "" {
157+
p.log.Infof("Unregistering runner %s from Gitea", runner.Name)
158+
if err := p.gitea.DeleteRunnerByName(shutdownCtx, runner.Name); err != nil {
159+
p.log.Warnf("Failed to unregister runner %s from Gitea: %v", runner.Name, err)
160+
// Continue with VM destruction even if unregistration fails
161+
}
162+
}
163+
164+
// Then destroy the VM
149165
if runner.VMID != "" {
150166
p.log.Infof("Stopping runner %s (VM: %s)", id, runner.VMID)
151167
if err := p.vmManager.DestroyVM(runner.VMID); err != nil {

modules/fireactions/registry-cache.nix

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -507,10 +507,12 @@ in
507507
cache-size = 1000;
508508
log-queries = false;
509509
# Use list for dhcp-range so it can merge with fireteact's range
510-
dhcp-range = [ "${dhcpStart},${dhcpEnd},${netmask},12h" ];
510+
# Use set: tag to scope this range, allowing per-subnet dhcp-options
511+
dhcp-range = [ "set:fireactions,${dhcpStart},${dhcpEnd},${netmask},12h" ];
512+
# Use tag: to scope options to fireactions subnet only
511513
dhcp-option = [
512-
"3,${gateway}"
513-
"6,${gateway}"
514+
"tag:fireactions,3,${gateway}" # Gateway for fireactions subnet
515+
"tag:fireactions,6,${gateway}" # DNS server for fireactions subnet
514516
];
515517
dhcp-rapid-commit = true;
516518
};

0 commit comments

Comments
 (0)