Skip to content

Commit bebe852

Browse files
committed
feat: add init, validate, status commands and health check endpoint
- `runscaler init`: interactive config file generator with hidden token input - `runscaler validate`: verify config, Docker, and GitHub API connectivity - `runscaler status`: query health endpoint to show runner status table - `--dry-run`: validate everything without starting listeners - `--health-port`: HTTP health check server with /healthz and /readyz - Environment variable token support (RUNSCALER_TOKEN, env:VAR_NAME syntax) - Improved help output with quick start examples
1 parent 5c3f0f5 commit bebe852

File tree

10 files changed

+635
-16
lines changed

10 files changed

+635
-16
lines changed

README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,24 @@ docker run --rm \
8585
### Run
8686

8787
```bash
88-
# Using a config file (recommended)
88+
# Generate config interactively
89+
runscaler init
90+
91+
# Validate everything before starting
92+
runscaler validate --config config.toml
93+
94+
# Start scaling
8995
runscaler --config config.toml
9096

91-
# Or using CLI flags
97+
# Or using CLI flags directly
9298
runscaler \
9399
--url https://github.com/your-org \
94100
--name my-runners \
95101
--token ghp_xxx \
96102
--max-runners 10
103+
104+
# Dry run — validate config, Docker, and images without starting listeners
105+
runscaler --dry-run --config config.toml
97106
```
98107

99108
Then in your workflow:
@@ -107,6 +116,15 @@ jobs:
107116
- run: echo "Running on auto-scaled runner!"
108117
```
109118
119+
## Commands
120+
121+
| Command | Description |
122+
|---------|-------------|
123+
| `runscaler` | Start the auto-scaler (default) |
124+
| `runscaler init` | Generate a config file interactively |
125+
| `runscaler validate` | Validate configuration and connectivity |
126+
| `runscaler status` | Show current runner status via health endpoint |
127+
110128
## Configuration
111129

112130
Configuration can be provided via a TOML config file (`--config`) or CLI flags. When both are provided, CLI flags take priority over config file values.
@@ -132,6 +150,21 @@ log-level = "info"
132150
log-format = "text"
133151
```
134152

153+
### Token Security
154+
155+
Avoid passing tokens as CLI flags (visible in `ps` output). Use one of these approaches:
156+
157+
```bash
158+
# Environment variable
159+
export RUNSCALER_TOKEN=ghp_xxx
160+
runscaler --url https://github.com/org --name my-runners
161+
162+
# In config file, reference an env var
163+
token = "env:GITHUB_TOKEN"
164+
```
165+
166+
Priority: CLI flag > `RUNSCALER_TOKEN` env var > config file value.
167+
135168
**Multiple scale sets (multi-org):**
136169

137170
```toml
@@ -179,6 +212,8 @@ labels = ["linux", "gpu"]
179212
| `--shared-volume` | | Shared Docker volume path in runners (e.g. `/shared`) |
180213
| `--log-level` | `info` | Log level (debug/info/warn/error) |
181214
| `--log-format` | `text` | Log format (text/json) |
215+
| `--dry-run` | `false` | Validate everything without starting listeners |
216+
| `--health-port` | `8080` | Health check HTTP port (0 to disable) |
182217

183218
## Deployment
184219

cmd_init.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/spf13/cobra"
11+
"golang.org/x/term"
12+
)
13+
14+
var initCmd = &cobra.Command{
15+
Use: "init",
16+
Short: "Generate a config file interactively",
17+
Long: "Create a config.toml file by answering a few questions. Flags can be used for non-interactive mode.",
18+
Example: ` # Interactive mode
19+
runscaler init
20+
21+
# Non-interactive mode
22+
runscaler init --url https://github.com/org --name my-runners --token ghp_xxx`,
23+
RunE: runInit,
24+
}
25+
26+
func init() {
27+
flags := initCmd.Flags()
28+
flags.String("url", "", "Registration URL (e.g. https://github.com/org)")
29+
flags.String("name", "", "Scale set name")
30+
flags.String("token", "", "Personal access token")
31+
flags.Int("max-runners", 10, "Maximum concurrent runners")
32+
flags.Bool("dind", true, "Enable Docker-in-Docker")
33+
flags.String("output", "config.toml", "Output file path")
34+
}
35+
36+
func runInit(cmd *cobra.Command, args []string) error {
37+
output, _ := cmd.Flags().GetString("output")
38+
39+
// Check if file exists
40+
if _, err := os.Stat(output); err == nil {
41+
overwrite, err := promptYN(fmt.Sprintf("%s already exists. Overwrite?", output), false)
42+
if err != nil {
43+
return err
44+
}
45+
if !overwrite {
46+
fmt.Println("Cancelled.")
47+
return nil
48+
}
49+
}
50+
51+
url, _ := cmd.Flags().GetString("url")
52+
name, _ := cmd.Flags().GetString("name")
53+
token, _ := cmd.Flags().GetString("token")
54+
maxRunners, _ := cmd.Flags().GetInt("max-runners")
55+
dind, _ := cmd.Flags().GetBool("dind")
56+
57+
// Interactive mode: prompt for missing values
58+
var err error
59+
if url == "" {
60+
url, err = promptString("GitHub registration URL (e.g. https://github.com/your-org)")
61+
if err != nil {
62+
return err
63+
}
64+
}
65+
if name == "" {
66+
name, err = promptString("Scale set name (used as runs-on label)")
67+
if err != nil {
68+
return err
69+
}
70+
}
71+
if token == "" {
72+
token, err = promptSecret("GitHub Personal Access Token")
73+
if err != nil {
74+
return err
75+
}
76+
}
77+
if !cmd.Flags().Changed("max-runners") {
78+
maxRunners, err = promptInt("Maximum concurrent runners", 10)
79+
if err != nil {
80+
return err
81+
}
82+
}
83+
if !cmd.Flags().Changed("dind") {
84+
dind, err = promptYN("Enable Docker-in-Docker?", true)
85+
if err != nil {
86+
return err
87+
}
88+
}
89+
90+
config := fmt.Sprintf(`# runscaler configuration
91+
# See: https://github.com/ysya/runscaler
92+
93+
# GitHub registration URL (organization or repository)
94+
url = %q
95+
96+
# Scale set name — used as the runs-on label in workflows
97+
name = %q
98+
99+
# Personal access token (consider using env:VARIABLE_NAME for security)
100+
# Example: token = "env:GITHUB_TOKEN"
101+
token = %q
102+
103+
# Runner limits
104+
max-runners = %d
105+
min-runners = 0
106+
107+
# Docker image for runners
108+
runner-image = "ghcr.io/actions/actions-runner:latest"
109+
110+
# Docker-in-Docker: mount host Docker socket into runners
111+
dind = %v
112+
113+
# Docker socket path
114+
docker-socket = "/var/run/docker.sock"
115+
116+
# Shared volume for cross-runner caching (optional)
117+
# shared-volume = "/shared"
118+
119+
# Logging
120+
log-level = "info"
121+
log-format = "text"
122+
123+
# Health check server port (0 to disable)
124+
# health-port = 8080
125+
126+
# --- Multi-org example ---
127+
# Uncomment and duplicate [[scaleset]] blocks for multiple orgs:
128+
#
129+
# [[scaleset]]
130+
# url = "https://github.com/org-a"
131+
# name = "runners-a"
132+
# token = "env:TOKEN_ORG_A"
133+
# max-runners = 5
134+
#
135+
# [[scaleset]]
136+
# url = "https://github.com/org-b"
137+
# name = "runners-b"
138+
# token = "env:TOKEN_ORG_B"
139+
`, url, name, token, maxRunners, dind)
140+
141+
if err := os.WriteFile(output, []byte(config), 0600); err != nil {
142+
return fmt.Errorf("failed to write %s: %w", output, err)
143+
}
144+
145+
fmt.Printf("\nCreated %s\n", output)
146+
fmt.Println("\nNext steps:")
147+
fmt.Printf(" runscaler validate --config %s # Verify configuration\n", output)
148+
fmt.Printf(" runscaler --config %s # Start scaling\n", output)
149+
return nil
150+
}
151+
152+
var reader = bufio.NewReader(os.Stdin)
153+
154+
func promptString(label string) (string, error) {
155+
for {
156+
fmt.Printf("%s: ", label)
157+
input, err := reader.ReadString('\n')
158+
if err != nil {
159+
return "", err
160+
}
161+
input = strings.TrimSpace(input)
162+
if input != "" {
163+
return input, nil
164+
}
165+
}
166+
}
167+
168+
func promptSecret(label string) (string, error) {
169+
fmt.Printf("%s: ", label)
170+
b, err := term.ReadPassword(int(os.Stdin.Fd()))
171+
fmt.Println() // newline after hidden input
172+
if err != nil {
173+
// Fall back to regular input if terminal is not available
174+
return promptString(label)
175+
}
176+
s := strings.TrimSpace(string(b))
177+
if s == "" {
178+
return promptSecret(label)
179+
}
180+
return s, nil
181+
}
182+
183+
func promptInt(label string, defaultVal int) (int, error) {
184+
fmt.Printf("%s [%d]: ", label, defaultVal)
185+
input, err := reader.ReadString('\n')
186+
if err != nil {
187+
return 0, err
188+
}
189+
input = strings.TrimSpace(input)
190+
if input == "" {
191+
return defaultVal, nil
192+
}
193+
v, err := strconv.Atoi(input)
194+
if err != nil {
195+
return 0, fmt.Errorf("invalid number: %s", input)
196+
}
197+
return v, nil
198+
}
199+
200+
func promptYN(label string, defaultVal bool) (bool, error) {
201+
defStr := "Y/n"
202+
if !defaultVal {
203+
defStr = "y/N"
204+
}
205+
fmt.Printf("%s [%s]: ", label, defStr)
206+
input, err := reader.ReadString('\n')
207+
if err != nil {
208+
return false, err
209+
}
210+
input = strings.TrimSpace(strings.ToLower(input))
211+
switch input {
212+
case "":
213+
return defaultVal, nil
214+
case "y", "yes":
215+
return true, nil
216+
case "n", "no":
217+
return false, nil
218+
default:
219+
return false, fmt.Errorf("invalid input: %s", input)
220+
}
221+
}

cmd_status.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"text/tabwriter"
10+
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var statusCmd = &cobra.Command{
15+
Use: "status",
16+
Short: "Show current runner status",
17+
Long: "Query the local health check endpoint to display runner status.",
18+
Example: ` runscaler status
19+
runscaler status --health-port 9090
20+
runscaler status --json`,
21+
RunE: runStatus,
22+
}
23+
24+
func init() {
25+
flags := statusCmd.Flags()
26+
flags.Int("health-port", 8080, "Health check server port to connect to")
27+
flags.Bool("json", false, "Output raw JSON")
28+
}
29+
30+
func runStatus(cmd *cobra.Command, args []string) error {
31+
port, _ := cmd.Flags().GetInt("health-port")
32+
jsonOutput, _ := cmd.Flags().GetBool("json")
33+
34+
url := fmt.Sprintf("http://localhost:%d/healthz", port)
35+
resp, err := http.Get(url)
36+
if err != nil {
37+
return fmt.Errorf("cannot connect to runscaler at port %d — is it running?\n\n"+
38+
" Start runscaler first: runscaler --config config.toml\n"+
39+
" Or check the health port: runscaler --health-port %d --config config.toml",
40+
port, port)
41+
}
42+
defer resp.Body.Close()
43+
44+
body, err := io.ReadAll(resp.Body)
45+
if err != nil {
46+
return fmt.Errorf("failed to read response: %w", err)
47+
}
48+
49+
if jsonOutput {
50+
fmt.Println(string(body))
51+
return nil
52+
}
53+
54+
var health HealthResponse
55+
if err := json.Unmarshal(body, &health); err != nil {
56+
return fmt.Errorf("failed to parse response: %w", err)
57+
}
58+
59+
fmt.Printf("runscaler %s (uptime: %s)\n\n", health.Version, health.Uptime)
60+
61+
if len(health.ScaleSets) == 0 {
62+
fmt.Println("No scale sets registered yet.")
63+
return nil
64+
}
65+
66+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
67+
fmt.Fprintln(w, "SCALE SET\tIDLE\tBUSY\tTOTAL")
68+
for _, ss := range health.ScaleSets {
69+
fmt.Fprintf(w, "%s\t%d\t%d\t%d\n", ss.Name, ss.Idle, ss.Busy, ss.Idle+ss.Busy)
70+
}
71+
w.Flush()
72+
73+
return nil
74+
}

0 commit comments

Comments
 (0)