Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,5 @@ Temporary Items

# End of https://www.toptal.com/developers/gitignore/api/macos,intellij,go

/myshoes*
/myshoes*
/server
65 changes: 51 additions & 14 deletions cmd/server/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/whywaita/myshoes/pkg/gh"
"github.com/whywaita/myshoes/pkg/logger"
"github.com/whywaita/myshoes/pkg/runner"
"github.com/whywaita/myshoes/pkg/scaleset"
"github.com/whywaita/myshoes/pkg/starter"
"github.com/whywaita/myshoes/pkg/starter/safety/unlimited"
"github.com/whywaita/myshoes/pkg/web"
Expand Down Expand Up @@ -53,6 +54,7 @@ type myShoes struct {
ds datastore.Datastore
start *starter.Starter
run *runner.Manager
ss *scaleset.Manager // nil if scale set mode disabled
}

// newShoes create myshoes.
Expand All @@ -69,10 +71,29 @@ func newShoes() (*myShoes, error) {

manager := runner.New(ds, config.Config.RunnerVersion)

var scalesetManager *scaleset.Manager
if config.Config.ScaleSetEnabled {
logger.Logf(false, "Scale set mode enabled")
scalesetManager = scaleset.New(ds, scaleset.ManagerConfig{
AppID: config.Config.GitHub.AppID,
PrivateKeyPEM: config.Config.GitHub.PEMByte,
GitHubURL: config.Config.GitHubURL,
RunnerGroupName: config.Config.ScaleSetRunnerGroup,
MaxRunners: config.Config.ScaleSetMaxRunners,
ScaleSetPrefix: config.Config.ScaleSetNamePrefix,
RunnerVersion: config.Config.RunnerVersion,
RunnerUser: config.Config.RunnerUser,
RunnerBaseDir: config.Config.RunnerBaseDirectory,
})
} else {
logger.Logf(false, "Scale set mode disabled (webhook mode)")
}

return &myShoes{
ds: ds,
start: s,
run: manager,
ss: scalesetManager,
}, nil
}

Expand All @@ -99,27 +120,43 @@ func (m *myShoes) Run() error {
time.Sleep(time.Second)
}

// Web server runs in both modes (provides REST API + metrics)
eg.Go(func() error {
if err := web.Serve(ctx, m.ds); err != nil {
logger.Logf(false, "failed to web.Serve: %+v", err)
return fmt.Errorf("failed to serve: %w", err)
}
return nil
})
eg.Go(func() error {
if err := m.start.Loop(ctx); err != nil {
logger.Logf(false, "failed to starter manager: %+v", err)
return fmt.Errorf("failed to starter loop: %w", err)
}
return nil
})
eg.Go(func() error {
if err := m.run.Loop(ctx); err != nil {
logger.Logf(false, "failed to runner manager: %+v", err)
return fmt.Errorf("failed to runner loop: %w", err)
}
return nil
})

if m.ss != nil {
// Scale set mode: use long-polling listener instead of webhook + job queue
logger.Logf(false, "Starting in scale set mode")
eg.Go(func() error {
if err := m.ss.Loop(ctx); err != nil {
logger.Logf(false, "failed to scaleset manager: %+v", err)
return fmt.Errorf("failed to scaleset loop: %w", err)
}
return nil
})
} else {
// Webhook mode: use traditional starter + runner loops
logger.Logf(false, "Starting in webhook mode")
eg.Go(func() error {
if err := m.start.Loop(ctx); err != nil {
logger.Logf(false, "failed to starter manager: %+v", err)
return fmt.Errorf("failed to starter loop: %w", err)
}
return nil
})
eg.Go(func() error {
if err := m.run.Loop(ctx); err != nil {
logger.Logf(false, "failed to runner manager: %+v", err)
return fmt.Errorf("failed to runner loop: %w", err)
}
return nil
})
}

if err := eg.Wait(); err != nil {
return fmt.Errorf("failed to wait errgroup: %w", err)
Expand Down
198 changes: 198 additions & 0 deletions docs/scaleset-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Scale Set Mode

## Overview

Scale set mode provides long-polling-driven auto-scaling using the GitHub Actions Runner Scale Set API. It switches communication from the traditional webhook mode (GitHub → myshoes, push) to myshoes → GitHub (pull).

### Differences from Webhook Mode

| Item | Webhook Mode (existing) | Scale Set Mode (new) |
|------|------------------------|----------------------|
| **Communication** | GitHub → myshoes (push) | myshoes → GitHub (pull) |
| **Trigger** | GitHub webhook | Long-polling API |
| **Runner registration** | Registration token + config.sh | JIT (Just-In-Time) config |
| **Startup speed** | Normal | Fast (via JIT config) |
| **Job queue** | Used | Not used |
| **Scaling** | Managed by starter/runner loop | Managed by scale set manager |

## GitHub App Permissions

Required GitHub App permissions for scale set mode:

| Permission | Repository scope | Organization scope | Reason |
|-----------|-----------------|-------------------|--------|
| `actions` | Read & Write | Read & Write | Runner registration/deletion (same as existing) |
| `administration` | Read | - | Read repository settings (same as existing) |
| `organization_self_hosted_runners` | - | Read & Write | **Newly required**: For organization-level scale set management |

**Important changes**:
- Repository-level targets: Work with existing permissions (no changes needed)
- **Organization-level targets**: The `organization_self_hosted_runners` permission is **newly required**

If you are already using organization-level targets in webhook mode, you need to add this permission.

Reference: [Authenticating ARC to the GitHub API - GitHub Docs](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/authenticate-to-the-api)

## Configuration

### Environment Variables

```bash
# Enable scale set mode
SCALESET_ENABLED=true # Enable scale set mode (default: false)
SCALESET_RUNNER_GROUP=default # Runner group name (default: "default")
SCALESET_MAX_RUNNERS=10 # Max runners per scale set (default: 10)
SCALESET_NAME_PREFIX=myshoes # Scale set name prefix (default: "myshoes")

# Existing environment variables (still required)
GITHUB_APP_ID=123456
GITHUB_PRIVATE_KEY_BASE64=...
GITHUB_URL=https://github.com # Change for GHES
RUNNER_VERSION=v2.311.0
RUNNER_USER=runner
RUNNER_BASE_DIRECTORY=/tmp
PLUGIN=./shoes-lxd
# ... other existing settings
```

### Behavior When Scale Set Mode Is Enabled

1. **Web server**: Starts (serves REST API + metrics)
2. **starter.Loop**: Does not start (replaced by scale set scaler)
3. **runner.Loop**: Does not start (replaced by HandleJobCompleted)
4. **scaleset.Manager.Loop**: Starts (new)

## Web Endpoint Changes

| Endpoint | Webhook Mode | Scale Set Mode | Reason |
|----------|-------------|----------------|--------|
| `/github/events` (POST) | Required | **Not required** | Does not receive webhooks from GitHub. Webhook URL in GitHub App settings is also unnecessary |
| `/target` (CRUD) | Required | **Required** | Scale set manager reads targets from the datastore |
| `/healthz` | Required | **Required** | Health check |
| `/metrics` | Required | **Required** | Prometheus metrics (scale set specific metrics are also added) |
| `/config/*` | Optional | **Optional** | Runtime configuration changes |

**Important**: When scale set mode is enabled, the `/github/events` endpoint exists but is not used. You do not need to configure a Webhook URL in your GitHub App settings.

## Flow Comparison

### Webhook Mode (existing)

```
GitHub Actions → webhook → myshoes → job queue
starter loop
shoes plugin → runner
runner manager (periodic cleanup)
```

### Scale Set Mode (new)

```
myshoes scale set manager → long-poll GitHub Scale Set API
↓ (JobAssigned event)
generate JIT config
shoes plugin → runner
↓ (JobCompleted event)
HandleJobCompleted → immediate cleanup
```

## JIT Runner Characteristics

- **No registration token needed**: Authentication credentials are included in the JIT config
- **No config.sh needed**: Starts directly with `./run.sh --jitconfig`
- **No RunnerService.js patch needed**: JIT runners are inherently ephemeral
- **Fast startup**: Token generation and config.sh steps are skipped

### Comparison with Traditional Setup Script

| Item | Webhook Mode | Scale Set Mode |
|------|-------------|----------------|
| Registration token retrieval | Required | Not required (included in JIT config) |
| `config.sh --unattended` | Executed | Not required |
| `RunnerService.js` patch | Applied | Not required |
| `--ephemeral`/`--once` flag | Required | Not required (JIT is inherently ephemeral) |
| Startup command | `./run.sh --once` | `./run.sh --jitconfig <encoded>` |

## Compatibility with Existing Shoes Providers

- **No proto changes**: The JIT config script is passed via the `setupScript` argument to `AddInstance`
- **Transparent support**: Providers handle it as a regular setup script
- **No migration needed**: Existing shoes-lxd, shoes-aws, and shoes-openstack work as-is

## Scale Set Naming Convention

- **Format**: `{SCALESET_NAME_PREFIX}-{sanitized-scope}`
- **Examples**:
- org `myorg` → scale set name `myshoes-myorg`
- repo `myorg/myrepo` → scale set name `myshoes-myorg-myrepo`
- **Sanitization**: `/` and other invalid characters are replaced with `-`

## Prometheus Metrics

Scale set mode specific metrics:

| Metric Name | Type | Labels | Description |
|------------|------|--------|-------------|
| `myshoes_scaleset_listener_running` | gauge | target_scope | Number of running scale set listeners |
| `myshoes_scaleset_desired_runners` | gauge | target_scope | Number of desired runners |
| `myshoes_scaleset_active_runners` | gauge | target_scope | Number of active runners |
| `myshoes_scaleset_jobs_completed_total` | counter | target_scope | Total number of completed jobs |
| `myshoes_scaleset_provision_errors_total` | counter | target_scope | Total number of provisioning errors |

## Limitations and Notes

1. **Mode exclusivity**: Scale set mode and webhook mode are mutually exclusive (global switch)
2. **Installation required**: GitHub App installation is required to create scale sets
3. **1 target = 1 scale set**: One scale set is created per target
4. **Runner group**: Specified at scale set creation (default: "default")
5. **GHES support**: Supported by setting the GHES URL via the `GITHUB_URL` environment variable
6. **Permissions**: Organization-level targets require additional permission (`organization_self_hosted_runners`)

## Migration Guide

### Migrating from Webhook Mode to Scale Set Mode

1. **Add GitHub App permissions** (if using organization-level targets)
- Add `organization_self_hosted_runners: Read & Write` in your GitHub App settings

2. **Set environment variables**
```bash
SCALESET_ENABLED=true
```

3. **Remove Webhook URL** (optional)
- You can safely remove the Webhook URL from your GitHub App settings (it is not used in scale set mode)

4. **Restart myshoes**
- Restart myshoes to apply the environment variable changes

5. **Verify operation**
- Confirm that `myshoes_scaleset_*` metrics are output at the `/metrics` endpoint
- Confirm that `Starting in scale set mode` appears in the logs
- Trigger a workflow job and confirm that a runner is provisioned

### Troubleshooting

**Scale set is not created**
- Verify the runner group name is correct (default: "default")
- Verify that the GitHub App installation is set up correctly
- Check the logs for `failed to get runner group` errors

**Runners are not provisioned**
- Check the `myshoes_scaleset_provision_errors_total` metric
- Check the logs for `failed to provision runner` errors
- Verify that the shoes plugin is working correctly

**Errors with organization-level targets**
- Verify that `organization_self_hosted_runners` has been added to the GitHub App permissions
- Verify that the installation ID is being retrieved correctly

## References

- [GitHub Actions Runner Scale Set (scaleset) - GitHub](https://github.com/actions/scaleset)
- [Authenticating ARC to the GitHub API - GitHub Docs](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/authenticate-to-the-api)
- [Actions Runner Controller (ARC) - GitHub](https://github.com/actions/actions-runner-controller)
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/whywaita/myshoes

go 1.25
go 1.25.3

require (
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0
Expand Down Expand Up @@ -30,6 +30,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/actions/scaleset v0.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
Expand All @@ -49,7 +50,10 @@ require (
github.com/google/go-github/v75 v75.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/actions/scaleset v0.1.0 h1:Rzov5AqcphrQV+VfcPWUAK+hdVJzzJihr/qof1YjZx8=
github.com/actions/scaleset v0.1.0/go.mod h1:ncR5vzCCTUSyLgvclAtZ5dRBgF6qwA2nbTfTXmOJp84=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 h1:SmbUK/GxpAspRjSQbB6ARvH+ArzlNzTtHydNyXUQ6zg=
Expand Down Expand Up @@ -73,10 +75,14 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
Expand Down
3 changes: 2 additions & 1 deletion internal/testutils/testutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ func IntegrationTestRunner(m *testing.M) int {
createTablesIfNotExist()
//SetupDefaultFixtures()

mux := web.NewMux(testDatastore)
// Tests use webhook mode (scale set mode disabled)
mux := web.NewMux(testDatastore, false)
ts := httptest.NewServer(mux)
testURL = ts.URL

Expand Down
9 changes: 9 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type Conf struct {

DockerHubCredential DockerHubCredential
ProvideDockerHubMetrics bool

ScaleSetEnabled bool
ScaleSetRunnerGroup string
ScaleSetMaxRunners int
ScaleSetNamePrefix string
}

// DockerHubCredential is type of config value
Expand Down Expand Up @@ -73,6 +78,10 @@ const (
EnvDockerHubUsername = "DOCKER_HUB_USERNAME"
EnvDockerHubPassword = "DOCKER_HUB_PASSWORD"
EnvProvideDockerHubMetrics = "PROVIDE_DOCKER_HUB_METRICS"
EnvScaleSetEnabled = "SCALESET_ENABLED"
EnvScaleSetRunnerGroup = "SCALESET_RUNNER_GROUP"
EnvScaleSetMaxRunners = "SCALESET_MAX_RUNNERS"
EnvScaleSetNamePrefix = "SCALESET_NAME_PREFIX"
)

// ModeWebhookType is type value for GitHub webhook
Expand Down
Loading