feat(schedules): 每台服务器可设计划任务,按星期和时区定时发命令、重启、停服、开服或备份,重启和备份前可在游戏内提醒
This commit is contained in:
32 files changed
+6011
-27
No files matched your search
@@ -370,6 +370,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
InternalBaseURL: internalAPIBaseURL(),
|
InternalBaseURL: internalAPIBaseURL(),
|
||||||
Submissions: submissions,
|
Submissions: submissions,
|
||||||
Mailer: mailer,
|
Mailer: mailer,
|
||||||
|
Schedules: repo,
|
||||||
// The external face authenticates the local session cookie the sign-in doors
|
// The external face authenticates the local session cookie the sign-in doors
|
||||||
// mint, live once `felis breakGlass` flips local_auth_enabled on. Cloudflare
|
// mint, live once `felis breakGlass` flips local_auth_enabled on. Cloudflare
|
||||||
// Access, when the install sits behind it, is enforced at the edge only.
|
// Access, when the install sits behind it, is enforced at the edge only.
|
||||||
@@ -464,6 +465,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
// reconciles it, but this loop converges builds nobody is polling.
|
// reconciles it, but this loop converges builds nobody is polling.
|
||||||
go reconcileBuilds(ctx, builder, stderr)
|
go reconcileBuilds(ctx, builder, stderr)
|
||||||
go settleRestoreChains(ctx, a, stderr)
|
go settleRestoreChains(ctx, a, stderr)
|
||||||
|
go runSchedules(ctx, a, stderr)
|
||||||
// A daily restore point of every world played since its last one, taken
|
// A daily restore point of every world played since its last one, taken
|
||||||
// once the server stops ([archive] scheduled_every; 0s turns it off).
|
// once the server stops ([archive] scheduled_every; 0s turns it off).
|
||||||
if backuper != nil && rcfg.ScheduledEvery > 0 {
|
if backuper != nil && rcfg.ScheduledEvery > 0 {
|
||||||
@@ -734,6 +736,24 @@ func settleRestoreChains(ctx context.Context, a *api.API, stderr io.Writer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runSchedules runs the servers' scheduled tasks (api.API.RunSchedules). The
|
||||||
|
// interval is how late a task may start, and how often a restart or backup in
|
||||||
|
// progress checks whether it can take its next step.
|
||||||
|
func runSchedules(ctx context.Context, a *api.API, stderr io.Writer) {
|
||||||
|
t := time.NewTicker(15 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := a.RunSchedules(ctx); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "felis api: scheduled tasks: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// scheduleBackups starts the scheduled backups (api.BackupScheduler). Each tick
|
// scheduleBackups starts the scheduled backups (api.BackupScheduler). Each tick
|
||||||
// starts at most one, so the interval also spaces the worlds that stopped at
|
// starts at most one, so the interval also spaces the worlds that stopped at
|
||||||
// the same time: a world that stops waits at most this long for its point to
|
// the same time: a world that stops waits at most this long for its point to
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ tags:
|
|||||||
description: Read (SSE) and write (RCON) server console (external face, app tier).
|
description: Read (SSE) and write (RCON) server console (external face, app tier).
|
||||||
- name: backups
|
- name: backups
|
||||||
description: World backup listing and restore (external face, app tier).
|
description: World backup listing and restore (external face, app tier).
|
||||||
|
- name: schedules
|
||||||
|
description: Scheduled tasks of your own servers (external face, app tier).
|
||||||
- name: account
|
- name: account
|
||||||
description: Web side of account linking (external face, app tier).
|
description: Web side of account linking (external face, app tier).
|
||||||
- name: admin-servers
|
- name: admin-servers
|
||||||
@@ -639,6 +641,68 @@ components:
|
|||||||
allOf: [{ $ref: '#/components/schemas/RetireState' }]
|
allOf: [{ $ref: '#/components/schemas/RetireState' }]
|
||||||
description: Owned rows only. Present while the server is given up or being deleted.
|
description: Owned rows only. Present while the server is given up or being deleted.
|
||||||
|
|
||||||
|
Schedule:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
One scheduled task of a server (internal/api/schedules.go Schedule). It runs
|
||||||
|
at minute_of_day on the weekdays, or with every_minutes set at every multiple
|
||||||
|
of it since midnight on those days, in timezone.
|
||||||
|
required: [id, server, label, action, command, every_minutes, minute_of_day, weekdays, timezone, warn_minutes, enabled, next_run_at, run_state, last_run_at, last_result, last_detail, created_by, created_at]
|
||||||
|
properties:
|
||||||
|
id: { type: integer, format: int64 }
|
||||||
|
server: { type: string }
|
||||||
|
label: { type: string, description: Free text naming the task; may be empty. }
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [command, restart, stop, start, backup]
|
||||||
|
description: >-
|
||||||
|
backup of a running server stops it, takes a backup (pruned with the daily
|
||||||
|
restore points, [archive] scheduled_keep) and starts it again; of a stopped
|
||||||
|
server it leaves the server stopped.
|
||||||
|
command: { type: string, description: The console command of a command task, without a slash; empty otherwise. }
|
||||||
|
every_minutes:
|
||||||
|
type: integer
|
||||||
|
enum: [0, 15, 30, 60, 120, 180, 240, 360, 480, 720]
|
||||||
|
description: 0 runs once a day at minute_of_day. A restart, stop, start or backup repeats at most every 60 minutes.
|
||||||
|
minute_of_day: { type: integer, minimum: 0, maximum: 1439, description: Minutes after local midnight; 0 when every_minutes is set. }
|
||||||
|
weekdays: { type: integer, minimum: 1, maximum: 127, description: 'Bitmask of the days it runs on: bit 0 Sunday to bit 6 Saturday.' }
|
||||||
|
timezone: { type: string, description: IANA zone the times are in, e.g. Asia/Shanghai. }
|
||||||
|
warn_minutes:
|
||||||
|
type: integer
|
||||||
|
enum: [0, 1, 5, 10, 15, 30]
|
||||||
|
description: How long before a restart, stop or backup the players on the server are told (say); 0 for none, and always 0 for a command or start.
|
||||||
|
enabled: { type: boolean }
|
||||||
|
next_run_at: { type: string, format: date-time, nullable: true, description: Null while disabled. }
|
||||||
|
run_state:
|
||||||
|
type: string
|
||||||
|
enum: ['', claimed, stopping, backing_up, starting]
|
||||||
|
description: What a run in progress is doing; empty when none is.
|
||||||
|
last_run_at: { type: string, format: date-time, nullable: true }
|
||||||
|
last_result:
|
||||||
|
type: string
|
||||||
|
enum: ['', ok, skipped, failed, missed]
|
||||||
|
description: >-
|
||||||
|
How the last run ended; empty before the first and during a run. missed is a
|
||||||
|
run felis-api was down for, dropped once it was 10 minutes late.
|
||||||
|
last_detail: { type: string, description: What happened, in English (a command's reply, or why the run was skipped or failed). }
|
||||||
|
created_by: { type: string }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
|
||||||
|
ScheduleInput:
|
||||||
|
type: object
|
||||||
|
required: [action, weekdays, timezone]
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
label: { type: string, maxLength: 64 }
|
||||||
|
action: { type: string, enum: [command, restart, stop, start, backup] }
|
||||||
|
command: { type: string, maxLength: 1024, description: Required for a command task and refused for the others. One line; a leading slash is dropped. }
|
||||||
|
every_minutes: { type: integer, enum: [0, 15, 30, 60, 120, 180, 240, 360, 480, 720] }
|
||||||
|
minute_of_day: { type: integer, minimum: 0, maximum: 1439 }
|
||||||
|
weekdays: { type: integer, minimum: 1, maximum: 127 }
|
||||||
|
timezone: { type: string }
|
||||||
|
warn_minutes: { type: integer, enum: [0, 1, 5, 10, 15, 30] }
|
||||||
|
enabled: { type: boolean, description: Default true. }
|
||||||
|
|
||||||
BackupView:
|
BackupView:
|
||||||
type: object
|
type: object
|
||||||
description: One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286).
|
description: One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286).
|
||||||
@@ -4595,6 +4659,201 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
|
# --------------------------------------------------- scheduled tasks (app) ---
|
||||||
|
/api/v1/servers/{name}/schedules:
|
||||||
|
get:
|
||||||
|
tags: [schedules]
|
||||||
|
operationId: listServerSchedules
|
||||||
|
summary: List a server's scheduled tasks (owner-or-admin).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The server's tasks, oldest first, and how many it may have.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [server, schedules, limit]
|
||||||
|
properties:
|
||||||
|
server: { type: string }
|
||||||
|
schedules:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/Schedule' }
|
||||||
|
limit: { type: integer }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
post:
|
||||||
|
tags: [schedules]
|
||||||
|
operationId: createServerSchedule
|
||||||
|
summary: Add a scheduled task to a server (owner-or-admin).
|
||||||
|
description: >-
|
||||||
|
The task belongs to the server's current owner: once the server has another
|
||||||
|
owner felis-api disables it instead of running it, until somebody saves it
|
||||||
|
again. felis-api checks the tasks every 15 seconds; a run it was down for is
|
||||||
|
started late, up to 10 minutes, and dropped as missed after that. A command
|
||||||
|
runs only on a running server, a restart only restarts a running one, and a
|
||||||
|
start goes through the running-server cap and a pending retirement like a
|
||||||
|
wake. Audited as schedule.create; each run as schedule.run by scheduler.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ScheduleInput' }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Schedule' }
|
||||||
|
'400':
|
||||||
|
description: A malformed body or name, or settings out of range (bad_schedule, bad_request for the command).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: The server already has 20 tasks (schedule_limit).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
/api/v1/servers/{name}/schedules/{id}:
|
||||||
|
put:
|
||||||
|
tags: [schedules]
|
||||||
|
operationId: updateServerSchedule
|
||||||
|
summary: Change a scheduled task (owner-or-admin).
|
||||||
|
description: >-
|
||||||
|
Replaces the task's settings and recomputes its next run. The task passes to
|
||||||
|
the server's current owner. Refused while a run is in progress. Audited as
|
||||||
|
schedule.update.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ScheduleInput' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Saved.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Schedule' }
|
||||||
|
'400':
|
||||||
|
description: A malformed body, name or id, or settings out of range (bad_schedule).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: A run is in progress (schedule_running).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
delete:
|
||||||
|
tags: [schedules]
|
||||||
|
operationId: deleteServerSchedule
|
||||||
|
summary: Remove a scheduled task (owner-or-admin).
|
||||||
|
description: Refused while a run is in progress. Audited as schedule.delete.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Removed.
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: A run is in progress (schedule_running).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
/api/v1/servers/{name}/schedules/{id}/run:
|
||||||
|
post:
|
||||||
|
tags: [schedules]
|
||||||
|
operationId: runServerSchedule
|
||||||
|
summary: Run a scheduled task now (owner-or-admin).
|
||||||
|
description: >-
|
||||||
|
Starts a run at once, without the players' warning, whether the task is
|
||||||
|
enabled or not; its next scheduled run stays where it was. The answer is the
|
||||||
|
task after the run's first step: a command, stop or start has finished, and a
|
||||||
|
restart or backup goes on in the background (run_state). Audited as
|
||||||
|
schedule.run_now, and the run itself as schedule.run.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Started.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Schedule' }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: >-
|
||||||
|
A run is already in progress (schedule_running), or the server has another
|
||||||
|
owner since the task was saved (schedule_stale).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
# ------------------------------------------------------ users (admin tier) ----
|
# ------------------------------------------------------ users (admin tier) ----
|
||||||
/api/v1/users:
|
/api/v1/users:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -3163,6 +3163,65 @@ end, has not been run on a cluster.]
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 19. Scheduled tasks: a run is skipped, missed or failed
|
||||||
|
|
||||||
|
A server's owner (or an admin) keeps up to 20 scheduled tasks on it, on the
|
||||||
|
panel's **Scheduled tasks** page (`/api/v1/servers/{name}/schedules`). Each one
|
||||||
|
sends a console command, restarts, stops, starts or backs up the server, once a
|
||||||
|
day at a set time or every 15 minutes to 12 hours, on the chosen weekdays, in an
|
||||||
|
IANA time zone (the browser's by default). A restart, stop or backup may warn the
|
||||||
|
players in game (`say`) 1 to 30 minutes ahead. felis-api runs the tasks itself: a
|
||||||
|
loop every 15 seconds, so a task starts within about 15 seconds of its time, and
|
||||||
|
the database hands each run to one felis-api only.
|
||||||
|
|
||||||
|
What each action does:
|
||||||
|
|
||||||
|
| Action | Server running | Server stopped |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| command | runs it over RCON (a leading `/` is dropped) | skipped |
|
||||||
|
| restart | stops the pod, then starts it | skipped |
|
||||||
|
| stop | stops it | skipped |
|
||||||
|
| start | skipped (a start that `Failed` is started over) | starts it, as the panel's start does (running-server cap, retiring server, busy world) |
|
||||||
|
| backup | stops it, takes the same backup Job as "Back up now", then starts it again | takes the backup |
|
||||||
|
|
||||||
|
A step has a deadline: the pod must stop within 15 minutes, the backup Job must
|
||||||
|
end within 45, and the start is given 15. A restart or backup that cannot finish
|
||||||
|
still starts the server again when it was running before, so a failed backup never
|
||||||
|
leaves a world offline.
|
||||||
|
|
||||||
|
The row's last result says how the latest run went:
|
||||||
|
|
||||||
|
| `last_result` | `last_detail` | What to do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ok` | empty, or the reply of a console command | nothing |
|
||||||
|
| `skipped` | `the server has a new owner since this schedule was saved; save it again to use it` | the server changed owner (claim, account migration, admin). The task switched itself off; the new owner reviews it and saves it to turn it back on |
|
||||||
|
| `skipped` | `the server was not running`, `the server was already stopped`, `the server was already running`, `the server has no world yet`, `the server is being given up or deleted`, `the server no longer exists`, and for a start `the cluster is at its running-server cap` or `the world is busy with …` | the run had nothing to act on; the next run tries again |
|
||||||
|
| `missed` | `felis-api was not running at the scheduled time` | felis-api was down more than 10 minutes past the time. The run is dropped, so a restart never lands hours late; the next one runs as usual |
|
||||||
|
| `failed` | `felis-api stopped in the middle of this run` | felis-api restarted during the run's first step (the claim was over 2 minutes old); check that the server is in the state you want |
|
||||||
|
| `failed` | `the server did not stop within 15 minutes` | see §1 and §2 for a pod that hangs; the backup did not run |
|
||||||
|
| `failed` | `another operation kept the world busy for 15 minutes` | a restore, file change or another backup held the world (§3b) |
|
||||||
|
| `failed` | `the backup failed: …`, `the backup did not finish within 45 minutes`, `the backup store is full; ask an administrator to free space` | §10 for backup Jobs; the Backups page shows the Job |
|
||||||
|
| `failed` | `could not start the server again: …` (after a restart or a backup) | the server stopped and could not be started again: the running-server cap, a server being given up, or a world still busy after 15 minutes of retries. Start it from the panel once the cause is gone (§1, §3b) |
|
||||||
|
| `failed` | `the server console could not be reached`, `the command failed: …` | the server's RCON (§1c) |
|
||||||
|
|
||||||
|
"Run now" starts a run at once, including on a switched-off task, and leaves the
|
||||||
|
next planned run where it was. While a run is in progress (`run_state` is
|
||||||
|
`claimed`, `stopping`, `backing_up` or `starting`) the task cannot be edited,
|
||||||
|
deleted or run again (`409 schedule_running`).
|
||||||
|
|
||||||
|
A time the clock skips at a daylight-saving change runs an hour early (the offset
|
||||||
|
before the jump applies); a time it repeats runs once, the first time. A time zone
|
||||||
|
the host no longer knows runs in UTC. Deleting a server drops its tasks, and a new
|
||||||
|
server of the same name starts with none. The audit actions are `schedule.create`,
|
||||||
|
`schedule.update`, `schedule.delete`, `schedule.run_now` (by a person) and
|
||||||
|
`schedule.run` (every finished run, with its result and detail).
|
||||||
|
|
||||||
|
[GO-TESTED: `TestScheduleNextRun`, `TestScheduleInputValidation`,
|
||||||
|
`TestScheduleRunnerBackup`, `TestScheduleRunnerRestart`, `TestScheduleRunnerStaleClaim`;
|
||||||
|
PG-TESTED: `TestScheduleStoreRunCAS`, `TestDueSchedules`, `TestSchedulesFollowTheServer`]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick reference: symptom → section
|
## Quick reference: symptom → section
|
||||||
|
|
||||||
| Symptom | Section |
|
| Symptom | Section |
|
||||||
@@ -3208,3 +3267,4 @@ end, has not been run on a cluster.]
|
|||||||
| `felis breakGlass` sends no code / shows `Root override`; `otp_skipped` in the audit | §17 |
|
| `felis breakGlass` sends no code / shows `Root override`; `otp_skipped` in the audit | §17 |
|
||||||
| How long sessions, codes and audit rows are kept; export audit rows | §17 |
|
| How long sessions, codes and audit rows are kept; export audit rows | §17 |
|
||||||
| Files page: a change or upload refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `volume_full`, `files_timeout`) | §18 |
|
| Files page: a change or upload refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `volume_full`, `files_timeout`) | §18 |
|
||||||
|
| A scheduled task shows `skipped`, `missed` or `failed`; a task switched itself off after an owner change | §19 |
|
||||||
@@ -98,6 +98,11 @@ type API struct {
|
|||||||
FileStage *fileedit.Stage
|
FileStage *fileedit.Stage
|
||||||
InternalBaseURL string
|
InternalBaseURL string
|
||||||
|
|
||||||
|
// Schedules stores the servers' scheduled tasks (schedules.go), which
|
||||||
|
// RunSchedules fires. Optional: when nil the schedule routes report 503 and
|
||||||
|
// RunSchedules does nothing.
|
||||||
|
Schedules ServerSchedules
|
||||||
|
|
||||||
// Submissions is the user-modpack approval lane (a user-directed extension over
|
// Submissions is the user-modpack approval lane (a user-directed extension over
|
||||||
// the §16 build subsystem; see internal/submit). It is optional: when
|
// the §16 build subsystem; see internal/submit). It is optional: when
|
||||||
// nil the /me/submissions and /submissions routes report 503 rather than 404, so
|
// nil the /me/submissions and /submissions routes report 503 rather than 404, so
|
||||||
@@ -576,6 +581,15 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/mkdir", h: a.handleMkdir},
|
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/mkdir", h: a.handleMkdir},
|
||||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/rename", h: a.handleRenameFile},
|
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/rename", h: a.handleRenameFile},
|
||||||
{Method: "PUT", Pattern: "/api/v1/servers/{name}/files/upload", h: a.handleUploadFile},
|
{Method: "PUT", Pattern: "/api/v1/servers/{name}/files/upload", h: a.handleUploadFile},
|
||||||
|
// Scheduled tasks (handlers_schedules.go): a console command, restart, stop,
|
||||||
|
// start or backup at set times, which felis-api's runner fires. App-tier and
|
||||||
|
// owner-or-admin inside the handler, like the console and power routes they
|
||||||
|
// automate; a schedule reaches nothing its owner could not do by hand.
|
||||||
|
{Method: "GET", Pattern: "/api/v1/servers/{name}/schedules", h: a.handleListSchedules},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/servers/{name}/schedules", h: a.handleCreateSchedule},
|
||||||
|
{Method: "PUT", Pattern: "/api/v1/servers/{name}/schedules/{id}", h: a.handleUpdateSchedule},
|
||||||
|
{Method: "DELETE", Pattern: "/api/v1/servers/{name}/schedules/{id}", h: a.handleDeleteSchedule},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/servers/{name}/schedules/{id}/run", h: a.handleRunSchedule},
|
||||||
// Account linking (spec §10), web side: /start reports link status (it is the
|
// Account linking (spec §10), web side: /start reports link status (it is the
|
||||||
// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
|
// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
|
||||||
// the account. App-tier, not admin — linking your own account is an ordinary
|
// the account. App-tier, not admin — linking your own account is an ordinary
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"felis.lolicon.best/internal/naming"
|
"felis.lolicon.best/internal/naming"
|
||||||
)
|
)
|
||||||
@@ -51,23 +50,10 @@ func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A console command is exactly one line. Trim surrounding space, strip a
|
// A console command is exactly one line (normalizeConsoleCommand).
|
||||||
// single leading '/' (players type "/say hi"; RCON wants "say hi"), then
|
command, err := normalizeConsoleCommand(body.Command)
|
||||||
// reject control characters so one request can never smuggle a second command
|
if err != nil {
|
||||||
// past a newline.
|
writeError(w, r, err)
|
||||||
command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(body.Command), "/"))
|
|
||||||
if command == "" {
|
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "command is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(command) > maxConsoleCommandLen {
|
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
|
||||||
"command too long (max %d bytes)", maxConsoleCommandLen))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.IndexFunc(command, func(c rune) bool { return c < 0x20 }) >= 0 {
|
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
|
||||||
"command must be a single line (no control characters)"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/naming"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scheduleRunTimeout bounds the first step of a run somebody asked for. The
|
||||||
|
// step outlives the request, so a client that hangs up cannot cut a stop off
|
||||||
|
// halfway.
|
||||||
|
const scheduleRunTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// scheduleServer resolves {name} for the schedule routes and applies their
|
||||||
|
// gate: 400 for a malformed name, 404 for a server that does not exist, 403
|
||||||
|
// for a caller who neither owns it nor is an admin, 503 without a store.
|
||||||
|
func (a *API) scheduleServer(w http.ResponseWriter, r *http.Request) (*ServerRecord, bool) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if err := naming.ValidateServerName(name); err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rec, err := a.Repo.ServerByName(r.Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
a.writeLookupError(w, r, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !a.isOwnerOrAdmin(principalFromContext(r.Context()), rec) {
|
||||||
|
writeError(w, r, errForbidden)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if a.Schedules == nil {
|
||||||
|
writeError(w, r, newError(http.StatusServiceUnavailable, "schedules_unavailable",
|
||||||
|
"scheduled tasks are not configured"))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleID parses {id}.
|
||||||
|
func scheduleID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_id", "invalid schedule id"))
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeScheduleError maps the store's schedule errors.
|
||||||
|
func (a *API) writeScheduleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrScheduleRunning):
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "schedule_running",
|
||||||
|
"this schedule is running; try again once the run finishes"))
|
||||||
|
case errors.Is(err, ErrScheduleLimit):
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "schedule_limit",
|
||||||
|
"a server can have at most %d scheduled tasks", maxSchedulesPerServer))
|
||||||
|
default:
|
||||||
|
a.writeLookupError(w, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditSchedule records a change to a schedule by the signed-in caller.
|
||||||
|
func (a *API) auditSchedule(r *http.Request, action string, s *Schedule) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
e := AuditEntry{Actor: auditActor(p), Action: action, ServerName: s.Server,
|
||||||
|
Payload: auditPayload(map[string]any{"schedule": s.ID, "action": s.Action})}
|
||||||
|
if p != nil {
|
||||||
|
e.ActorUserID = p.UserID
|
||||||
|
}
|
||||||
|
a.auditEntry(r, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSchedules serves GET /servers/{name}/schedules.
|
||||||
|
func (a *API) handleListSchedules(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := a.scheduleServer(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := a.Schedules.ListSchedules(r.Context(), rec.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if list == nil {
|
||||||
|
list = []Schedule{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"server": rec.Name, "schedules": list, "limit": maxSchedulesPerServer})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateSchedule serves POST /servers/{name}/schedules. The schedule
|
||||||
|
// belongs to the server's current owner (see Schedule.OwnerID).
|
||||||
|
func (a *API) handleCreateSchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := a.scheduleServer(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in scheduleInput
|
||||||
|
if err := decodeJSON(w, r, &in); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := &Schedule{Server: rec.Name, OwnerID: rec.OwnerID, CreatedBy: auditActor(principalFromContext(r.Context()))}
|
||||||
|
if err := in.apply(s); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.NextRunAt = a.firstRun(s)
|
||||||
|
if err := a.Schedules.CreateSchedule(r.Context(), s, maxSchedulesPerServer); err != nil {
|
||||||
|
a.writeScheduleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.auditSchedule(r, "schedule.create", s)
|
||||||
|
writeJSON(w, http.StatusCreated, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateSchedule serves PUT /servers/{name}/schedules/{id}: new settings,
|
||||||
|
// and the schedule passes to the server's current owner.
|
||||||
|
func (a *API) handleUpdateSchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := a.scheduleServer(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := scheduleID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in scheduleInput
|
||||||
|
if err := decodeJSON(w, r, &in); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
|
||||||
|
if err != nil {
|
||||||
|
a.writeScheduleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := in.apply(s); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.OwnerID, s.NextRunAt = rec.OwnerID, a.firstRun(s)
|
||||||
|
if err := a.Schedules.UpdateSchedule(r.Context(), s); err != nil {
|
||||||
|
a.writeScheduleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.auditSchedule(r, "schedule.update", s)
|
||||||
|
writeJSON(w, http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteSchedule serves DELETE /servers/{name}/schedules/{id}.
|
||||||
|
func (a *API) handleDeleteSchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := a.scheduleServer(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := scheduleID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
|
||||||
|
if err != nil {
|
||||||
|
a.writeScheduleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Schedules.DeleteSchedule(r.Context(), rec.Name, id); err != nil {
|
||||||
|
a.writeScheduleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.auditSchedule(r, "schedule.delete", s)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRunSchedule serves POST /servers/{name}/schedules/{id}/run: the run
|
||||||
|
// starts now, without the players' warning, and the next scheduled run stays
|
||||||
|
// where it is. The answer is the schedule after the run's first step; a restart
|
||||||
|
// or backup goes on in the background.
|
||||||
|
func (a *API) handleRunSchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec, ok := a.scheduleServer(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := scheduleID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
|
||||||
|
if err != nil {
|
||||||
|
a.writeScheduleError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.OwnerID != rec.OwnerID {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "schedule_stale",
|
||||||
|
"the server has a new owner since this schedule was saved; save it again first"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), scheduleRunTimeout)
|
||||||
|
defer cancel()
|
||||||
|
now := a.now()
|
||||||
|
claimed, err := a.Schedules.ClaimScheduleRun(ctx, id, nil, nil, now)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
a.writeScheduleError(w, r, ErrScheduleRunning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.auditSchedule(r, "schedule.run_now", s)
|
||||||
|
if err := a.beginRun(ctx, s); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
after, err := a.Schedules.GetSchedule(ctx, rec.Name, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstRun is the next run of a schedule just saved, nil while it is disabled.
|
||||||
|
func (a *API) firstRun(s *Schedule) *time.Time {
|
||||||
|
if !s.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ptrTime(s.nextRun(a.now()))
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
|
|||||||
"MyServerView": MyServerView{},
|
"MyServerView": MyServerView{},
|
||||||
"AllowlistEntry": AllowlistEntry{},
|
"AllowlistEntry": AllowlistEntry{},
|
||||||
"BackupView": BackupView{},
|
"BackupView": BackupView{},
|
||||||
|
"Schedule": Schedule{},
|
||||||
"Build": build.Build{},
|
"Build": build.Build{},
|
||||||
"Image": build.Image{},
|
"Image": build.Image{},
|
||||||
"BuildScan": buildScanView{},
|
"BuildScan": buildScanView{},
|
||||||
|
|||||||
+17
-6
@@ -830,11 +830,11 @@ func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]ServerOwnership,
|
|||||||
// a create whose CRD write failed, or one the reaper deleted between removing
|
// a create whose CRD write failed, or one the reaper deleted between removing
|
||||||
// its MinecraftServer and marking the row. That row starts over, and the earlier
|
// its MinecraftServer and marking the row. That row starts over, and the earlier
|
||||||
// server's other aliases and allowlist go with it; nothing of its owner, claim,
|
// server's other aliases and allowlist go with it; nothing of its owner, claim,
|
||||||
// activity clock, reaper warnings or pending retirement reaches the new server,
|
// activity clock, reaper warnings, pending retirement or scheduled tasks reaches
|
||||||
// so the reaper's unfinished deletion no longer applies to it. A retried create
|
// the new server, so the reaper's unfinished deletion no longer applies to it. A
|
||||||
// lands on the same fresh state. The alias subdomain is a PRIMARY KEY: bound to
|
// retried create lands on the same fresh state. The alias subdomain is a PRIMARY
|
||||||
// another server, it rolls the whole seed back and returns ErrConflict, letting
|
// KEY: bound to another server, it rolls the whole seed back and returns
|
||||||
// the create handler answer 409 before it touches the CRD.
|
// ErrConflict, letting the create handler answer 409 before it touches the CRD.
|
||||||
//
|
//
|
||||||
// Two creates of one name racing between the handler's cluster check and the
|
// Two creates of one name racing between the handler's cluster check and the
|
||||||
// first CRD write can still leave the loser's alias in place of the winner's;
|
// first CRD write can still leave the loser's alias in place of the winner's;
|
||||||
@@ -862,6 +862,9 @@ func (p *PGRepo) SeedServer(ctx context.Context, name, subdomain string, cpuMill
|
|||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM server_aliases WHERE server_name = $1`, name); err != nil {
|
if _, err := tx.ExecContext(ctx, `DELETE FROM server_aliases WHERE server_name = $1`, name); err != nil {
|
||||||
return fmt.Errorf("clear earlier aliases: %w", err)
|
return fmt.Errorf("clear earlier aliases: %w", err)
|
||||||
}
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM server_schedules WHERE server_name = $1`, name); err != nil {
|
||||||
|
return fmt.Errorf("clear earlier schedules: %w", err)
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO server_aliases (subdomain, server_name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
`INSERT INTO server_aliases (subdomain, server_name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
subdomain, name); err != nil {
|
subdomain, name); err != nil {
|
||||||
@@ -2613,7 +2616,8 @@ func (p *PGRepo) RedeemMigration(ctx context.Context, targetUserID, codeHash str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-point every server the source owns to the target, collecting the names for
|
// Re-point every server the source owns to the target, collecting the names for
|
||||||
// the audit trail. Server ownership is the only thing that moves.
|
// the audit trail. Server ownership moves, and the servers' scheduled tasks
|
||||||
|
// with it: they belong to the same person.
|
||||||
rows, err := tx.QueryContext(ctx,
|
rows, err := tx.QueryContext(ctx,
|
||||||
`UPDATE servers SET owner_id = $2, claimed_at = $3
|
`UPDATE servers SET owner_id = $2, claimed_at = $3
|
||||||
WHERE owner_id = $1 AND deleted_at IS NULL
|
WHERE owner_id = $1 AND deleted_at IS NULL
|
||||||
@@ -2637,6 +2641,13 @@ func (p *PGRepo) RedeemMigration(ctx context.Context, targetUserID, codeHash str
|
|||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE server_schedules s SET owner_id = $2 FROM servers v
|
||||||
|
WHERE v.name = s.server_name AND v.owner_id = $2 AND s.owner_id = $1`,
|
||||||
|
sourceUserID, targetUserID); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Retire the source: revoke its live sessions and soft-delete it so it can neither
|
// Retire the source: revoke its live sessions and soft-delete it so it can neither
|
||||||
// log in nor start another migration (double-spend defense). The servers just moved
|
// log in nor start another migration (double-spend defense). The servers just moved
|
||||||
// away, so there is nothing left to release.
|
// away, so there is nothing left to release.
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The server_schedules store (ServerSchedules, migration 0036).
|
||||||
|
|
||||||
|
// scheduleColumns is the SELECT list scanSchedule reads, over server_schedules
|
||||||
|
// aliased s.
|
||||||
|
const scheduleColumns = `s.id, s.server_name, COALESCE(s.owner_id, ''), s.label, s.action, s.command,
|
||||||
|
s.every_minutes, s.minute_of_day, s.weekdays, s.timezone, s.warn_minutes, s.enabled,
|
||||||
|
s.next_run_at, s.warned_for, s.run_state, s.run_resume, s.run_step_at,
|
||||||
|
s.last_run_at, s.last_result, s.last_detail, s.created_by, s.created_at`
|
||||||
|
|
||||||
|
type rowScanner interface{ Scan(dest ...any) error }
|
||||||
|
|
||||||
|
func scanSchedule(row rowScanner, extra ...any) (*Schedule, error) {
|
||||||
|
var s Schedule
|
||||||
|
var next, warned, step, last sql.NullTime
|
||||||
|
dest := []any{&s.ID, &s.Server, &s.OwnerID, &s.Label, &s.Action, &s.Command,
|
||||||
|
&s.EveryMinutes, &s.MinuteOfDay, &s.Weekdays, &s.Timezone, &s.WarnMinutes, &s.Enabled,
|
||||||
|
&next, &warned, &s.RunState, &s.RunResume, &step,
|
||||||
|
&last, &s.LastResult, &s.LastDetail, &s.CreatedBy, &s.CreatedAt}
|
||||||
|
if err := row.Scan(append(dest, extra...)...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.NextRunAt, s.WarnedFor, s.RunStepAt, s.LastRunAt = nullTimePtr(next), nullTimePtr(warned), nullTimePtr(step), nullTimePtr(last)
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullTimePtr(t sql.NullTime) *time.Time {
|
||||||
|
if !t.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSchedules lists a server's schedules, oldest first.
|
||||||
|
func (p *PGRepo) ListSchedules(ctx context.Context, server string) ([]Schedule, error) {
|
||||||
|
rows, err := p.db.QueryContext(ctx,
|
||||||
|
`SELECT `+scheduleColumns+` FROM server_schedules s WHERE s.server_name = $1 ORDER BY s.id`, server)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Schedule
|
||||||
|
for rows.Next() {
|
||||||
|
s, err := scanSchedule(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSchedule reads one schedule of server, or ErrNotFound.
|
||||||
|
func (p *PGRepo) GetSchedule(ctx context.Context, server string, id int64) (*Schedule, error) {
|
||||||
|
s, err := scanSchedule(p.db.QueryRowContext(ctx,
|
||||||
|
`SELECT `+scheduleColumns+` FROM server_schedules s WHERE s.id = $1 AND s.server_name = $2`, id, server))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSchedule inserts s under the server row's lock, so two saves racing
|
||||||
|
// for the last free place cannot both take it.
|
||||||
|
func (p *PGRepo) CreateSchedule(ctx context.Context, s *Schedule, limit int) error {
|
||||||
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||||
|
|
||||||
|
var one int
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT 1 FROM servers WHERE name = $1 AND deleted_at IS NULL FOR UPDATE`, s.Server).Scan(&one); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM server_schedules WHERE server_name = $1`, s.Server).Scan(&n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n >= limit {
|
||||||
|
return ErrScheduleLimit
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`INSERT INTO server_schedules (server_name, owner_id, label, action, command, every_minutes,
|
||||||
|
minute_of_day, weekdays, timezone, warn_minutes, enabled, next_run_at, created_by)
|
||||||
|
VALUES ($1, NULLIF($2, ''), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
s.Server, s.OwnerID, s.Label, s.Action, s.Command, s.EveryMinutes,
|
||||||
|
s.MinuteOfDay, s.Weekdays, s.Timezone, s.WarnMinutes, s.Enabled, s.NextRunAt, s.CreatedBy,
|
||||||
|
).Scan(&s.ID, &s.CreatedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleMissing tells why a write guarded on an idle run_state touched no
|
||||||
|
// row: the schedule is gone, or it is running.
|
||||||
|
func (p *PGRepo) scheduleMissing(ctx context.Context, server string, id int64) error {
|
||||||
|
var state string
|
||||||
|
err := p.db.QueryRowContext(ctx,
|
||||||
|
`SELECT run_state FROM server_schedules WHERE id = $1 AND server_name = $2`, id, server).Scan(&state)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return ErrNotFound
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
case state != "":
|
||||||
|
return ErrScheduleRunning
|
||||||
|
}
|
||||||
|
return fmt.Errorf("schedule %d of %s did not change", id, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSchedule writes s's settings, owner and next run, unless it is running.
|
||||||
|
func (p *PGRepo) UpdateSchedule(ctx context.Context, s *Schedule) error {
|
||||||
|
res, err := p.db.ExecContext(ctx,
|
||||||
|
`UPDATE server_schedules SET owner_id = NULLIF($3, ''), label = $4, action = $5, command = $6,
|
||||||
|
every_minutes = $7, minute_of_day = $8, weekdays = $9, timezone = $10, warn_minutes = $11,
|
||||||
|
enabled = $12, next_run_at = $13, warned_for = NULL, updated_at = now()
|
||||||
|
WHERE id = $1 AND server_name = $2 AND run_state = ''`,
|
||||||
|
s.ID, s.Server, s.OwnerID, s.Label, s.Action, s.Command,
|
||||||
|
s.EveryMinutes, s.MinuteOfDay, s.Weekdays, s.Timezone, s.WarnMinutes,
|
||||||
|
s.Enabled, s.NextRunAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, err := res.RowsAffected(); err != nil || n == 0 {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.scheduleMissing(ctx, s.Server, s.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSchedule removes a schedule, unless it is running.
|
||||||
|
func (p *PGRepo) DeleteSchedule(ctx context.Context, server string, id int64) error {
|
||||||
|
res, err := p.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM server_schedules WHERE id = $1 AND server_name = $2 AND run_state = ''`, id, server)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, err := res.RowsAffected(); err != nil || n == 0 {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.scheduleMissing(ctx, server, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DueSchedules lists the schedules the runner has to look at, runs in progress
|
||||||
|
// first, then by due time.
|
||||||
|
func (p *PGRepo) DueSchedules(ctx context.Context, horizon time.Time) ([]DueSchedule, error) {
|
||||||
|
rows, err := p.db.QueryContext(ctx,
|
||||||
|
`SELECT `+scheduleColumns+`, COALESCE(v.owner_id, '')
|
||||||
|
FROM server_schedules s JOIN servers v ON v.name = s.server_name
|
||||||
|
WHERE v.deleted_at IS NULL AND (s.run_state <> '' OR (s.enabled AND s.next_run_at <= $1))
|
||||||
|
ORDER BY s.run_state = '', s.next_run_at, s.id`, horizon)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []DueSchedule
|
||||||
|
for rows.Next() {
|
||||||
|
var owner string
|
||||||
|
s, err := scanSchedule(rows, &owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, DueSchedule{Schedule: *s, ServerOwner: owner})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// execApplied runs a compare-and-set write and reports whether it matched.
|
||||||
|
func (p *PGRepo) execApplied(ctx context.Context, query string, args ...any) (bool, error) {
|
||||||
|
res, err := p.db.ExecContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimScheduleRun starts a run of a schedule without one.
|
||||||
|
func (p *PGRepo) ClaimScheduleRun(ctx context.Context, id int64, due, next *time.Time, now time.Time) (bool, error) {
|
||||||
|
if due == nil {
|
||||||
|
return p.execApplied(ctx,
|
||||||
|
`UPDATE server_schedules SET run_state = 'claimed', run_resume = false, run_step_at = $2,
|
||||||
|
last_run_at = $2, last_result = '', last_detail = ''
|
||||||
|
WHERE id = $1 AND run_state = ''`, id, now)
|
||||||
|
}
|
||||||
|
return p.execApplied(ctx,
|
||||||
|
`UPDATE server_schedules SET run_state = 'claimed', run_resume = false, run_step_at = $4,
|
||||||
|
last_run_at = $4, last_result = '', last_detail = '', next_run_at = $3, warned_for = NULL
|
||||||
|
WHERE id = $1 AND run_state = '' AND enabled AND next_run_at = $2`, id, *due, next, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvanceScheduleRun moves a run on to its next step.
|
||||||
|
func (p *PGRepo) AdvanceScheduleRun(ctx context.Context, id int64, from, to string, resume bool, result, detail string, now time.Time) (bool, error) {
|
||||||
|
return p.execApplied(ctx,
|
||||||
|
`UPDATE server_schedules SET run_state = $3, run_resume = $4, run_step_at = $7,
|
||||||
|
last_result = CASE WHEN $5::text = '' THEN last_result ELSE $5::text END,
|
||||||
|
last_detail = CASE WHEN $5::text = '' THEN last_detail ELSE $6::text END
|
||||||
|
WHERE id = $1 AND run_state = $2`, id, from, to, resume, result, detail, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishScheduleRun ends a run with its outcome.
|
||||||
|
func (p *PGRepo) FinishScheduleRun(ctx context.Context, id int64, from, result, detail string) (bool, error) {
|
||||||
|
return p.execApplied(ctx,
|
||||||
|
`UPDATE server_schedules SET run_state = '', run_resume = false, run_step_at = NULL,
|
||||||
|
last_result = $3, last_detail = $4
|
||||||
|
WHERE id = $1 AND run_state = $2::text AND $2::text <> ''`, id, from, result, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MissScheduleRun records the run due then as missed and moves on to next.
|
||||||
|
func (p *PGRepo) MissScheduleRun(ctx context.Context, id int64, due, next time.Time, detail string) (bool, error) {
|
||||||
|
return p.execApplied(ctx,
|
||||||
|
`UPDATE server_schedules SET next_run_at = $3, warned_for = NULL,
|
||||||
|
last_run_at = $2, last_result = 'missed', last_detail = $4
|
||||||
|
WHERE id = $1 AND run_state = '' AND enabled AND next_run_at = $2`, id, due, next, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarnScheduleRun marks the run due then as warned about.
|
||||||
|
func (p *PGRepo) WarnScheduleRun(ctx context.Context, id int64, due time.Time) (bool, error) {
|
||||||
|
return p.execApplied(ctx,
|
||||||
|
`UPDATE server_schedules SET warned_for = $2
|
||||||
|
WHERE id = $1 AND run_state = '' AND enabled AND next_run_at = $2
|
||||||
|
AND warned_for IS DISTINCT FROM $2`, id, due)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableSchedule turns off an enabled, idle schedule and records why.
|
||||||
|
func (p *PGRepo) DisableSchedule(ctx context.Context, id int64, detail string) (bool, error) {
|
||||||
|
return p.execApplied(ctx,
|
||||||
|
`UPDATE server_schedules SET enabled = false, next_run_at = NULL, warned_for = NULL,
|
||||||
|
last_result = 'skipped', last_detail = $2, updated_at = now()
|
||||||
|
WHERE id = $1 AND run_state = '' AND enabled`, id, detail)
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
"felis.lolicon.best/internal/maintenance"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The schedule runner. cmd/felis calls RunSchedules every few seconds; each
|
||||||
|
// call warns the players about the runs coming up, fires the runs that are
|
||||||
|
// due and moves every run in progress one step on. All of its state is in
|
||||||
|
// server_schedules, so a felis-api restart picks a restart or backup up where
|
||||||
|
// it was.
|
||||||
|
//
|
||||||
|
// A command, stop or start is one step. A restart stops the server (runStopping)
|
||||||
|
// and starts it once it is down (runStarting). A backup of a running server
|
||||||
|
// stops it, takes a backup once the world volume is free (runBackingUp), waits
|
||||||
|
// for the backup Job and starts the server again; a backup of a stopped server
|
||||||
|
// leaves it stopped. Each step gives up after its own wait, and a run that took
|
||||||
|
// the server down tries to bring it back when it gives up.
|
||||||
|
|
||||||
|
// Run steps (Schedule.RunState).
|
||||||
|
const (
|
||||||
|
runClaimed = "claimed"
|
||||||
|
runStopping = "stopping"
|
||||||
|
runBackingUp = "backing_up"
|
||||||
|
runStarting = "starting"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// scheduleMissGrace is how late a run may still start: felis-api back from
|
||||||
|
// a short restart catches up, and a run hours late is dropped as missed.
|
||||||
|
scheduleMissGrace = 10 * time.Minute
|
||||||
|
// claimedStale is how long a run may sit in its first step before it is
|
||||||
|
// taken for one whose felis-api stopped mid-step. The first step is a few
|
||||||
|
// API calls and at most one RCON round trip.
|
||||||
|
claimedStale = 2 * time.Minute
|
||||||
|
// stopWait is how long a run waits for the server to stop and its world
|
||||||
|
// volume to come free. A pod saving a big world takes a while.
|
||||||
|
stopWait = 15 * time.Minute
|
||||||
|
// backupWait is how long a run waits for its backup Job: past the Job's own
|
||||||
|
// deadline ([archive] backup Job deadline, 30m by default).
|
||||||
|
backupWait = 45 * time.Minute
|
||||||
|
// startWait is how long a run keeps trying to start the server again while
|
||||||
|
// the cluster is at its running cap or the world volume is still busy.
|
||||||
|
startWait = 15 * time.Minute
|
||||||
|
// backupJobSkew is how much earlier than the step the backup Job's creation
|
||||||
|
// stamp may read: felis-api's clock and the API server's differ a little.
|
||||||
|
backupJobSkew = 2 * time.Minute
|
||||||
|
// scheduleWarnHorizon is the longest warning lead time, in minutes.
|
||||||
|
scheduleWarnHorizon = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audit identity of a run. Each run leaves one schedule.run row when it ends.
|
||||||
|
const (
|
||||||
|
scheduleActor = "scheduler"
|
||||||
|
scheduleRunAction = "schedule.run"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunSchedules warns about, fires and advances the schedules once. A schedule
|
||||||
|
// that fails is logged and left for the next call; it never holds up the rest.
|
||||||
|
func (a *API) RunSchedules(ctx context.Context) error {
|
||||||
|
if a.Schedules == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := a.now()
|
||||||
|
due, err := a.Schedules.DueSchedules(ctx, now.Add(scheduleWarnHorizon*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list the due schedules: %w", err)
|
||||||
|
}
|
||||||
|
for i := range due {
|
||||||
|
d := &due[i]
|
||||||
|
if err := a.tickSchedule(ctx, d, now); err != nil {
|
||||||
|
log.Printf("api: schedule %d of %s: %v", d.ID, d.Server, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickSchedule does what one due schedule needs now.
|
||||||
|
func (a *API) tickSchedule(ctx context.Context, d *DueSchedule, now time.Time) error {
|
||||||
|
s := &d.Schedule
|
||||||
|
if s.RunState != "" {
|
||||||
|
return a.advanceRun(ctx, s, now)
|
||||||
|
}
|
||||||
|
// Somebody else's server now: their commands must not run on it. Saving
|
||||||
|
// the schedule again (PUT) hands it to the new owner.
|
||||||
|
if d.ServerOwner != s.OwnerID {
|
||||||
|
_, err := a.Schedules.DisableSchedule(ctx, s.ID,
|
||||||
|
"the server has a new owner since this schedule was saved; save it again to use it")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
due := *s.NextRunAt // set on every enabled schedule DueSchedules returns idle
|
||||||
|
if now.Before(due) {
|
||||||
|
return a.warnRun(ctx, s, due, now)
|
||||||
|
}
|
||||||
|
next := s.nextRun(now)
|
||||||
|
if now.Sub(due) > scheduleMissGrace {
|
||||||
|
_, err := a.Schedules.MissScheduleRun(ctx, s.ID, due, next,
|
||||||
|
"felis-api was not running at the scheduled time")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, err := a.Schedules.ClaimScheduleRun(ctx, s.ID, &due, &next, now)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.beginRun(ctx, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnRun tells the players on the server that a restart, stop or backup is
|
||||||
|
// coming, once per run, when its warning time has come.
|
||||||
|
func (a *API) warnRun(ctx context.Context, s *Schedule, due, now time.Time) error {
|
||||||
|
lead := time.Duration(s.WarnMinutes) * time.Minute
|
||||||
|
if now.Before(due.Add(-lead)) || a.Console == nil {
|
||||||
|
return nil // no warning, or not yet
|
||||||
|
}
|
||||||
|
info, err := a.Cluster.GetServer(ctx, s.Server)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.Ready {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ok, err := a.Schedules.WarnScheduleRun(ctx, s.ID, due)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Warned late (felis-api was restarting at the warning time): say how long
|
||||||
|
// is really left.
|
||||||
|
minutes := int(math.Ceil(due.Sub(now).Minutes()))
|
||||||
|
if _, err := a.Console.RunCommand(ctx, s.Server, "say "+scheduleWarning(s.Action, minutes)); err != nil {
|
||||||
|
return fmt.Errorf("warn the players: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleWarning is the in-game line announcing a run minutes ahead, in both
|
||||||
|
// panel languages.
|
||||||
|
func scheduleWarning(action string, minutes int) string {
|
||||||
|
switch action {
|
||||||
|
case ScheduleRestart:
|
||||||
|
return fmt.Sprintf("[Felis] 服务器将在 %d 分钟后重启 / Server restarts in %d min", minutes, minutes)
|
||||||
|
case ScheduleStop:
|
||||||
|
return fmt.Sprintf("[Felis] 服务器将在 %d 分钟后关闭 / Server stops in %d min", minutes, minutes)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("[Felis] 服务器将在 %d 分钟后暂停做备份,完成后自动恢复 / Server pauses for a backup in %d min and comes back after", minutes, minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// beginRun takes the first step of a run just claimed (runClaimed).
|
||||||
|
func (a *API) beginRun(ctx context.Context, s *Schedule) error {
|
||||||
|
finish := func(result, detail string) error { return a.finishRun(ctx, s, runClaimed, result, detail) }
|
||||||
|
info, err := a.Cluster.GetServer(ctx, s.Server)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return finish(ScheduleSkipped, "the server no longer exists")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return finish(ScheduleFailed, "could not read the server: "+err.Error())
|
||||||
|
}
|
||||||
|
running := info.DesiredState == string(v1alpha1.DesiredRunning)
|
||||||
|
|
||||||
|
switch s.Action {
|
||||||
|
case ScheduleCommand:
|
||||||
|
if !info.Ready {
|
||||||
|
return finish(ScheduleSkipped, "the server was not running")
|
||||||
|
}
|
||||||
|
if a.Console == nil {
|
||||||
|
return finish(ScheduleFailed, "the console is not configured")
|
||||||
|
}
|
||||||
|
out, err := a.Console.RunCommand(ctx, s.Server, s.Command)
|
||||||
|
if errors.Is(err, ErrConsoleUnavailable) {
|
||||||
|
return finish(ScheduleFailed, "the server console could not be reached")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return finish(ScheduleFailed, "the command failed: "+err.Error())
|
||||||
|
}
|
||||||
|
return finish(ScheduleOK, stripFormatting(out))
|
||||||
|
|
||||||
|
case ScheduleStop:
|
||||||
|
if !running {
|
||||||
|
return finish(ScheduleSkipped, "the server was already stopped")
|
||||||
|
}
|
||||||
|
if err := a.Cluster.SetDesiredState(ctx, s.Server, v1alpha1.DesiredStopped); err != nil {
|
||||||
|
return finish(ScheduleFailed, "could not stop the server: "+err.Error())
|
||||||
|
}
|
||||||
|
return finish(ScheduleOK, "")
|
||||||
|
|
||||||
|
case ScheduleStart:
|
||||||
|
if running && info.Phase != string(v1alpha1.PhaseFailed) {
|
||||||
|
return finish(ScheduleSkipped, "the server was already running")
|
||||||
|
}
|
||||||
|
why, _, err := a.startScheduled(ctx, s.Server)
|
||||||
|
if err != nil {
|
||||||
|
return finish(ScheduleFailed, "could not start the server: "+err.Error())
|
||||||
|
}
|
||||||
|
if why != "" {
|
||||||
|
return finish(ScheduleSkipped, why)
|
||||||
|
}
|
||||||
|
return finish(ScheduleOK, "")
|
||||||
|
|
||||||
|
case ScheduleRestart:
|
||||||
|
if !running {
|
||||||
|
return finish(ScheduleSkipped, "the server was not running")
|
||||||
|
}
|
||||||
|
return a.stopForRun(ctx, s, true)
|
||||||
|
|
||||||
|
case ScheduleBackup:
|
||||||
|
if _, ok := a.Backuper.(ScheduledBackuper); !ok {
|
||||||
|
return finish(ScheduleFailed, "backups are not configured")
|
||||||
|
}
|
||||||
|
if limit := a.BackupStoreCap; limit > 0 {
|
||||||
|
used, err := a.Repo.BackupStoreBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return finish(ScheduleFailed, "could not read the backup store size: "+err.Error())
|
||||||
|
}
|
||||||
|
if used >= limit {
|
||||||
|
return finish(ScheduleFailed, "the backup store is full; ask an administrator to free space")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exists, err := a.Cluster.WorldVolumeExists(ctx, s.Server)
|
||||||
|
if err != nil {
|
||||||
|
return finish(ScheduleFailed, "could not look up the world volume: "+err.Error())
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return finish(ScheduleSkipped, "the server has no world yet")
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
return a.stopForRun(ctx, s, true)
|
||||||
|
}
|
||||||
|
// Already stopped: back it up as soon as the world volume is free, and
|
||||||
|
// leave it stopped afterwards.
|
||||||
|
if ok, err := a.Schedules.AdvanceScheduleRun(ctx, s.ID, runClaimed, runStopping, false, "", "", a.now()); err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.RunState, s.RunResume, s.RunStepAt = runStopping, false, ptrTime(a.now())
|
||||||
|
return a.advanceRun(ctx, s, a.now())
|
||||||
|
}
|
||||||
|
return finish(ScheduleFailed, "unknown action "+s.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopForRun records the stop step and then stops the server, in that order: a
|
||||||
|
// felis-api that dies between the two leaves a running server in runStopping,
|
||||||
|
// which the next call reads as someone having started it, and nothing is lost.
|
||||||
|
func (a *API) stopForRun(ctx context.Context, s *Schedule, resume bool) error {
|
||||||
|
now := a.now()
|
||||||
|
if ok, err := a.Schedules.AdvanceScheduleRun(ctx, s.ID, runClaimed, runStopping, resume, "", "", now); err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.Cluster.SetDesiredState(ctx, s.Server, v1alpha1.DesiredStopped); err != nil {
|
||||||
|
return a.finishRun(ctx, s, runStopping, ScheduleFailed, "could not stop the server: "+err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceRun moves a run in progress on by one step, or leaves it waiting.
|
||||||
|
func (a *API) advanceRun(ctx context.Context, s *Schedule, now time.Time) error {
|
||||||
|
waited := now.Sub(*s.RunStepAt)
|
||||||
|
switch s.RunState {
|
||||||
|
case runClaimed:
|
||||||
|
if waited < claimedStale {
|
||||||
|
return nil // its first step is running right now
|
||||||
|
}
|
||||||
|
return a.finishRun(ctx, s, runClaimed, ScheduleFailed, "felis-api stopped in the middle of this run")
|
||||||
|
case runStopping:
|
||||||
|
return a.advanceStopping(ctx, s, waited)
|
||||||
|
case runBackingUp:
|
||||||
|
return a.advanceBackingUp(ctx, s, waited)
|
||||||
|
case runStarting:
|
||||||
|
return a.advanceStarting(ctx, s, waited)
|
||||||
|
}
|
||||||
|
return a.finishRun(ctx, s, s.RunState, ScheduleFailed, "unknown run step "+s.RunState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceStopping waits for the server to go down. A restart then starts it;
|
||||||
|
// a backup takes the world volume and starts the backup Job.
|
||||||
|
func (a *API) advanceStopping(ctx context.Context, s *Schedule, waited time.Duration) error {
|
||||||
|
info, err := a.Cluster.GetServer(ctx, s.Server)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return a.finishRun(ctx, s, runStopping, ScheduleSkipped, "the server no longer exists")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Only this run stops the server; wanted running again means a person (or
|
||||||
|
// a player's join) started it meanwhile, and their start stands.
|
||||||
|
if info.DesiredState == string(v1alpha1.DesiredRunning) {
|
||||||
|
return a.finishRun(ctx, s, runStopping, ScheduleSkipped, "someone started the server before the run finished")
|
||||||
|
}
|
||||||
|
giveUp := func(detail string) error {
|
||||||
|
if waited < stopWait {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.RunResume {
|
||||||
|
if err := a.Cluster.SetDesiredState(ctx, s.Server, v1alpha1.DesiredRunning); err == nil {
|
||||||
|
detail += "; it was started again"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.finishRun(ctx, s, runStopping, ScheduleFailed, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Action == ScheduleRestart {
|
||||||
|
if info.Phase != string(v1alpha1.PhaseStopped) {
|
||||||
|
return giveUp("the server did not stop within 15 minutes")
|
||||||
|
}
|
||||||
|
if ok, err := a.Schedules.AdvanceScheduleRun(ctx, s.ID, runStopping, runStarting, s.RunResume, "", "", a.now()); err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.RunState, s.RunStepAt = runStarting, ptrTime(a.now())
|
||||||
|
return a.advanceStarting(ctx, s, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, ok := a.Backuper.(ScheduledBackuper)
|
||||||
|
if !ok {
|
||||||
|
return a.finishRun(ctx, s, runStopping, ScheduleFailed, "backups are not configured")
|
||||||
|
}
|
||||||
|
switch err := a.Cluster.AcquireMaintenance(ctx, s.Server, maintenance.KindBackup); {
|
||||||
|
case errors.Is(err, ErrNotStopped):
|
||||||
|
return giveUp("the server did not stop within 15 minutes")
|
||||||
|
case errors.Is(err, ErrMaintenanceInProgress):
|
||||||
|
return giveUp("another operation kept the world busy for 15 minutes")
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
return a.finishRun(ctx, s, runStopping, ScheduleSkipped, "the server no longer exists")
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = b.BackupScheduled(ctx, s.Server, s.OwnerID)
|
||||||
|
// Once the Job exists it holds the world; the lock only covered the gap.
|
||||||
|
if rerr := a.Cluster.ReleaseMaintenance(context.WithoutCancel(ctx), s.Server); rerr != nil {
|
||||||
|
log.Printf("api: release the maintenance lock on %s: %v (it lapses after %s)", s.Server, rerr, maintenance.Grace)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
detail := "could not start the backup: " + err.Error()
|
||||||
|
if s.RunResume {
|
||||||
|
if serr := a.Cluster.SetDesiredState(ctx, s.Server, v1alpha1.DesiredRunning); serr == nil {
|
||||||
|
detail += "; the server was started again"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.finishRun(ctx, s, runStopping, ScheduleFailed, detail)
|
||||||
|
}
|
||||||
|
log.Printf("api: schedule %d started a backup of %s", s.ID, s.Server)
|
||||||
|
_, err = a.Schedules.AdvanceScheduleRun(ctx, s.ID, runStopping, runBackingUp, s.RunResume, "", "", a.now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceBackingUp waits for the backup Job and records how it ended; a run
|
||||||
|
// that stopped a running server then starts it again.
|
||||||
|
func (a *API) advanceBackingUp(ctx context.Context, s *Schedule, waited time.Duration) error {
|
||||||
|
result, detail := ScheduleOK, ""
|
||||||
|
if a.JobStatus != nil {
|
||||||
|
jobs, err := a.JobStatus.LatestJobs(ctx, s.Server)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var job *AsyncJob
|
||||||
|
for i := range jobs {
|
||||||
|
j := &jobs[i]
|
||||||
|
if j.Scheduled && !j.StartedAt.Before(s.RunStepAt.Add(-backupJobSkew)) {
|
||||||
|
job = j
|
||||||
|
break // newest first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case job == nil && waited < backupJobSkew:
|
||||||
|
return nil // not listed yet
|
||||||
|
case job == nil:
|
||||||
|
result, detail = ScheduleFailed, "the backup Job is gone before it could be checked; see the Backups page"
|
||||||
|
case job.State == "running" && waited < backupWait:
|
||||||
|
return nil
|
||||||
|
case job.State == "running":
|
||||||
|
result, detail = ScheduleFailed, "the backup did not finish within 45 minutes"
|
||||||
|
case job.State == "failed":
|
||||||
|
result, detail = ScheduleFailed, strings.TrimSpace("the backup failed: "+job.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !s.RunResume {
|
||||||
|
return a.finishRun(ctx, s, runBackingUp, result, detail)
|
||||||
|
}
|
||||||
|
if ok, err := a.Schedules.AdvanceScheduleRun(ctx, s.ID, runBackingUp, runStarting, true, result, detail, a.now()); err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.RunState, s.RunStepAt, s.LastResult, s.LastDetail = runStarting, ptrTime(a.now()), result, detail
|
||||||
|
return a.advanceStarting(ctx, s, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceStarting brings the server back up after a restart's stop or a
|
||||||
|
// backup. The outcome recorded so far (the backup's) is kept when it starts.
|
||||||
|
func (a *API) advanceStarting(ctx context.Context, s *Schedule, waited time.Duration) error {
|
||||||
|
why, retry, err := a.startScheduled(ctx, s.Server)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return a.finishRun(ctx, s, runStarting, ScheduleSkipped, "the server no longer exists")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if why == "" {
|
||||||
|
result, detail := s.LastResult, s.LastDetail
|
||||||
|
if result == "" {
|
||||||
|
result = ScheduleOK
|
||||||
|
}
|
||||||
|
return a.finishRun(ctx, s, runStarting, result, detail)
|
||||||
|
}
|
||||||
|
if retry && waited < startWait {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
detail := "could not start the server again: " + why
|
||||||
|
if s.LastResult == ScheduleFailed {
|
||||||
|
detail = s.LastDetail + "; " + detail
|
||||||
|
}
|
||||||
|
return a.finishRun(ctx, s, runStarting, ScheduleFailed, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startScheduled starts a server for a run: the wake path without the
|
||||||
|
// per-player cooldown. why names what kept it stopped ("" once it is wanted
|
||||||
|
// running), and retry says whether that may clear by itself.
|
||||||
|
func (a *API) startScheduled(ctx context.Context, name string) (why string, retry bool, err error) {
|
||||||
|
rec, err := a.Repo.ServerByName(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if rec.Retire != nil {
|
||||||
|
return "the server is being given up or deleted", false, nil
|
||||||
|
}
|
||||||
|
info, err := a.Cluster.GetServer(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
ok, err := a.withinRunningCap(ctx, info)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "the cluster is at its running-server cap", true, nil
|
||||||
|
}
|
||||||
|
// A start that Failed is started over, as a person's start does (handleStart).
|
||||||
|
if info.Phase == string(v1alpha1.PhaseFailed) && info.DesiredState == string(v1alpha1.DesiredRunning) {
|
||||||
|
err = a.Cluster.RetryStart(ctx, name)
|
||||||
|
} else {
|
||||||
|
err = a.Cluster.SetDesiredState(ctx, name, v1alpha1.DesiredRunning)
|
||||||
|
}
|
||||||
|
var busy *MaintenanceBusyError
|
||||||
|
if errors.As(err, &busy) {
|
||||||
|
return "the world is busy with " + maintenanceLabel(busy.Kind), true, nil
|
||||||
|
}
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishRun ends a run at step from and audits it.
|
||||||
|
func (a *API) finishRun(ctx context.Context, s *Schedule, from, result, detail string) error {
|
||||||
|
detail = truncateUTF8(detail, maxScheduleDetail)
|
||||||
|
ok, err := a.Schedules.FinishScheduleRun(ctx, s.ID, from, result, detail)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.writeAudit(ctx, AuditEntry{
|
||||||
|
Actor: scheduleActor, Source: scheduleActor, Action: scheduleRunAction, ServerName: s.Server,
|
||||||
|
Payload: auditPayload(map[string]any{"schedule": s.ID, "action": s.Action, "result": result, "detail": detail}),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripFormatting drops Minecraft's section-sign formatting codes from a
|
||||||
|
// console reply and trims it.
|
||||||
|
func stripFormatting(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
skip := false
|
||||||
|
for _, c := range s {
|
||||||
|
switch {
|
||||||
|
case skip:
|
||||||
|
skip = false
|
||||||
|
case c == '§':
|
||||||
|
skip = true
|
||||||
|
default:
|
||||||
|
b.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrTime(t time.Time) *time.Time { return &t }
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
"felis.lolicon.best/internal/maintenance"
|
||||||
|
)
|
||||||
|
|
||||||
|
// outcome asserts how a schedule's last run ended and that no run is left.
|
||||||
|
func (r *schedRig) outcome(t *testing.T, id int64, result, detail string) *Schedule {
|
||||||
|
t.Helper()
|
||||||
|
s := r.st.row(t, id)
|
||||||
|
if s.RunState != "" || s.LastResult != result || s.LastDetail != detail {
|
||||||
|
t.Fatalf("run state %q, result %q %q; want done with %q %q", s.RunState, s.LastResult, s.LastDetail, result, detail)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// step asserts a run waits at step.
|
||||||
|
func (r *schedRig) step(t *testing.T, id int64, step string) {
|
||||||
|
t.Helper()
|
||||||
|
if s := r.st.row(t, id); s.RunState != step {
|
||||||
|
t.Fatalf("run state %q (result %q %q), want %q", s.RunState, s.LastResult, s.LastDetail, step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAudits lists the schedule.run rows as result:detail.
|
||||||
|
func (r *schedRig) runAudits() []string {
|
||||||
|
var out []string
|
||||||
|
for _, e := range r.repo.audits {
|
||||||
|
if e.Action == scheduleRunAction && e.Actor == scheduleActor && e.Source == scheduleActor {
|
||||||
|
out = append(out, string(e.Payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleRunnerFiring(t *testing.T) {
|
||||||
|
t.Run("a due command runs, moves on a day and is audited", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.con.reply = " §6There are §c2§6 players online "
|
||||||
|
id := r.schedule(ScheduleCommand, schedT0, func(s *Schedule) { s.Command = "list" })
|
||||||
|
r.clock = schedT0.Add(20 * time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
s := r.outcome(t, id, ScheduleOK, "There are 2 players online")
|
||||||
|
if !sameTime(s.NextRunAt, schedT0.AddDate(0, 0, 1)) || !sameTime(s.LastRunAt, r.clock) || r.con.gotCommand != "list" {
|
||||||
|
t.Fatalf("next %v, last %v, console %q", s.NextRunAt, s.LastRunAt, r.con.gotCommand)
|
||||||
|
}
|
||||||
|
if got := r.runAudits(); len(got) != 1 ||
|
||||||
|
got[0] != `{"action":"command","detail":"There are 2 players online","result":"ok","schedule":1}` {
|
||||||
|
t.Fatalf("run audits %v", got)
|
||||||
|
}
|
||||||
|
r.tick(t) // not due again
|
||||||
|
if r.con.calls != 1 || len(r.runAudits()) != 1 {
|
||||||
|
t.Fatalf("ran again: %d console calls", r.con.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not due yet: nothing happens", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleCommand, schedT0.Add(time.Second))
|
||||||
|
r.tick(t)
|
||||||
|
if s := r.st.row(t, id); r.con.calls != 0 || s.LastRunAt != nil || !sameTime(s.NextRunAt, schedT0.Add(time.Second)) {
|
||||||
|
t.Fatalf("ran early: %+v", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a long reply is cut at 500 bytes on a rune boundary", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.con.reply = "x" + strings.Repeat("猫", 200)
|
||||||
|
id := r.schedule(ScheduleCommand, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
if s := r.st.row(t, id); s.LastDetail != "x"+strings.Repeat("猫", 166) {
|
||||||
|
t.Fatalf("detail is %d bytes: %q", len(s.LastDetail), s.LastDetail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a command on a stopped server is skipped", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
id := r.schedule(ScheduleCommand, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleSkipped, "the server was not running")
|
||||||
|
if r.con.calls != 0 {
|
||||||
|
t.Fatal("dialed a stopped server")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an unreachable console fails the run", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.con.err = ErrConsoleUnavailable
|
||||||
|
id := r.schedule(ScheduleCommand, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "the server console could not be reached")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ten minutes late still runs; later is missed", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
late := r.schedule(ScheduleCommand, schedT0.Add(-scheduleMissGrace))
|
||||||
|
missed := r.schedule(ScheduleCommand, schedT0.Add(-scheduleMissGrace-time.Second))
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, late, ScheduleOK, "")
|
||||||
|
s := r.outcome(t, missed, ScheduleMissed, "felis-api was not running at the scheduled time")
|
||||||
|
if r.con.calls != 1 || !sameTime(s.LastRunAt, schedT0.Add(-scheduleMissGrace-time.Second)) ||
|
||||||
|
!sameTime(s.NextRunAt, time.Date(2026, 9, 29, 2, 49, 0, 0, time.UTC)) {
|
||||||
|
t.Fatalf("console calls %d, missed row %+v", r.con.calls, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a new owner disables the schedule instead of running it", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.st.owners["survival"] = "newowner"
|
||||||
|
id := r.schedule(ScheduleCommand, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
s := r.outcome(t, id, ScheduleSkipped, "the server has a new owner since this schedule was saved; save it again to use it")
|
||||||
|
if s.Enabled || s.NextRunAt != nil || r.con.calls != 0 {
|
||||||
|
t.Fatalf("still enabled or ran: %+v, %d console calls", s, r.con.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the schedule of a released server stops too", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.st.owners["survival"] = ""
|
||||||
|
id := r.schedule(ScheduleStop, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
if s := r.st.row(t, id); s.Enabled || r.cl.desired["survival"] != "" {
|
||||||
|
t.Fatalf("ran on a released server: %+v", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stop", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleStop, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleOK, "")
|
||||||
|
if r.cl.desired["survival"] != v1alpha1.DesiredStopped {
|
||||||
|
t.Fatalf("desired %q", r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
r2 := newSchedRig(t)
|
||||||
|
r2.stopped()
|
||||||
|
id = r2.schedule(ScheduleStop, schedT0)
|
||||||
|
r2.tick(t)
|
||||||
|
r2.outcome(t, id, ScheduleSkipped, "the server was already stopped")
|
||||||
|
if r2.cl.desired["survival"] != "" {
|
||||||
|
t.Fatal("stopped a stopped server again")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a server gone since the schedule was saved", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
delete(r.cl.byName, "survival")
|
||||||
|
id := r.schedule(ScheduleStop, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleSkipped, "the server no longer exists")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleRunnerStart(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
setup func(r *schedRig)
|
||||||
|
result string
|
||||||
|
detail string
|
||||||
|
desired v1alpha1.DesiredState
|
||||||
|
retried bool
|
||||||
|
}{
|
||||||
|
{"a stopped server starts", func(r *schedRig) { r.stopped() }, ScheduleOK, "", v1alpha1.DesiredRunning, false},
|
||||||
|
{"a running server is left alone", func(*schedRig) {}, ScheduleSkipped, "the server was already running", "", false},
|
||||||
|
{"a failed server is retried", func(r *schedRig) { r.cl.byName["survival"].Phase = string(v1alpha1.PhaseFailed) },
|
||||||
|
ScheduleOK, "", v1alpha1.DesiredRunning, true},
|
||||||
|
{"a failed start of a stopped server starts plainly", func(r *schedRig) {
|
||||||
|
r.stopped()
|
||||||
|
r.cl.byName["survival"].Phase = string(v1alpha1.PhaseFailed)
|
||||||
|
}, ScheduleOK, "", v1alpha1.DesiredRunning, false},
|
||||||
|
{"the running cap holds it back", func(r *schedRig) {
|
||||||
|
r.stopped()
|
||||||
|
r.a.MaxRunningServers = 1
|
||||||
|
r.cl.list = []ServerInfo{{Name: "other", DesiredState: string(v1alpha1.DesiredRunning)}}
|
||||||
|
}, ScheduleSkipped, "the cluster is at its running-server cap", "", false},
|
||||||
|
{"a pending retirement holds it back", func(r *schedRig) {
|
||||||
|
r.stopped()
|
||||||
|
r.repo.byName["survival"].Retire = &RetireState{}
|
||||||
|
}, ScheduleSkipped, "the server is being given up or deleted", "", false},
|
||||||
|
{"a busy world holds it back", func(r *schedRig) {
|
||||||
|
r.stopped()
|
||||||
|
r.cl.wakeErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindBackup}
|
||||||
|
}, ScheduleSkipped, "the world is busy with a backup", "", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
tc.setup(r)
|
||||||
|
id := r.schedule(ScheduleStart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, tc.result, tc.detail)
|
||||||
|
if r.cl.desired["survival"] != tc.desired || (len(r.cl.retried) == 1) != tc.retried {
|
||||||
|
t.Fatalf("desired %q, retried %v", r.cl.desired["survival"], r.cl.retried)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleRunnerWarning(t *testing.T) {
|
||||||
|
t.Run("told once, at the warning time", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
due := schedT0.Add(5 * time.Minute)
|
||||||
|
id := r.schedule(ScheduleRestart, due, func(s *Schedule) { s.WarnMinutes = 5 })
|
||||||
|
r.clock = schedT0.Add(-time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
if r.con.calls != 0 {
|
||||||
|
t.Fatal("warned early")
|
||||||
|
}
|
||||||
|
r.clock = schedT0
|
||||||
|
r.tick(t)
|
||||||
|
r.clock = schedT0.Add(15 * time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
if r.con.calls != 1 || r.con.gotCommand != "say [Felis] 服务器将在 5 分钟后重启 / Server restarts in 5 min" {
|
||||||
|
t.Fatalf("%d warnings, last %q", r.con.calls, r.con.gotCommand)
|
||||||
|
}
|
||||||
|
if s := r.st.row(t, id); !sameTime(s.WarnedFor, due) || s.LastRunAt != nil {
|
||||||
|
t.Fatalf("after the warning %+v", s)
|
||||||
|
}
|
||||||
|
r.clock = due
|
||||||
|
r.tick(t) // the run itself claims and clears the marker
|
||||||
|
if s := r.st.row(t, id); s.WarnedFor != nil || s.RunState != runStopping {
|
||||||
|
t.Fatalf("after the run started %+v", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("late warnings say how long is really left", func(t *testing.T) {
|
||||||
|
for action, want := range map[string]string{
|
||||||
|
ScheduleStop: "say [Felis] 服务器将在 2 分钟后关闭 / Server stops in 2 min",
|
||||||
|
ScheduleBackup: "say [Felis] 服务器将在 2 分钟后暂停做备份,完成后自动恢复 / Server pauses for a backup in 2 min and comes back after",
|
||||||
|
} {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.schedule(action, schedT0.Add(90*time.Second), func(s *Schedule) { s.WarnMinutes = 10 })
|
||||||
|
r.tick(t)
|
||||||
|
if r.con.gotCommand != want {
|
||||||
|
t.Fatalf("%s warned %q, want %q", action, r.con.gotCommand, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nobody to tell on a stopped server, none without a lead time", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
a := r.schedule(ScheduleRestart, schedT0.Add(time.Minute), func(s *Schedule) { s.WarnMinutes = 5 })
|
||||||
|
r.tick(t)
|
||||||
|
r2 := newSchedRig(t)
|
||||||
|
r2.schedule(ScheduleRestart, schedT0.Add(time.Minute))
|
||||||
|
r2.tick(t)
|
||||||
|
if r.con.calls != 0 || r2.con.calls != 0 || r.st.row(t, a).WarnedFor != nil {
|
||||||
|
t.Fatalf("warned: %d / %d", r.con.calls, r2.con.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleRunnerRestart(t *testing.T) {
|
||||||
|
t.Run("stops, waits for the pod, starts", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStopping)
|
||||||
|
if r.cl.desired["survival"] != v1alpha1.DesiredStopped {
|
||||||
|
t.Fatalf("desired %q", r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
// Still shutting down.
|
||||||
|
info := r.cl.byName["survival"]
|
||||||
|
info.DesiredState = string(v1alpha1.DesiredStopped)
|
||||||
|
r.clock = schedT0.Add(time.Minute)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStopping)
|
||||||
|
info.Ready = false
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStopping) // not Ready, but not Stopped either
|
||||||
|
info.Phase = string(v1alpha1.PhaseStopped)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleOK, "")
|
||||||
|
if r.cl.desired["survival"] != v1alpha1.DesiredRunning {
|
||||||
|
t.Fatalf("desired %q", r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
if got := r.runAudits(); len(got) != 1 || got[0] != `{"action":"restart","detail":"","result":"ok","schedule":1}` {
|
||||||
|
t.Fatalf("run audits %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a stopped server is not restarted", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleSkipped, "the server was not running")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("someone starting it meanwhile ends the run", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.clock = schedT0.Add(30 * time.Second)
|
||||||
|
r.tick(t) // info still says desired Running: a person started it again
|
||||||
|
r.outcome(t, id, ScheduleSkipped, "someone started the server before the run finished")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a pod that does not stop in 15 minutes: give up and start it again", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.cl.byName["survival"].DesiredState = string(v1alpha1.DesiredStopped)
|
||||||
|
r.clock = schedT0.Add(stopWait - time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStopping)
|
||||||
|
r.clock = schedT0.Add(stopWait)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "the server did not stop within 15 minutes; it was started again")
|
||||||
|
if r.cl.desired["survival"] != v1alpha1.DesiredRunning {
|
||||||
|
t.Fatalf("desired %q", r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the running cap: keep trying for 15 minutes, then fail", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
r.a.MaxRunningServers = 1
|
||||||
|
r.cl.list = []ServerInfo{{Name: "other", DesiredState: string(v1alpha1.DesiredRunning)}}
|
||||||
|
r.clock = schedT0.Add(time.Minute)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStarting)
|
||||||
|
r.clock = schedT0.Add(time.Minute + startWait - time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStarting)
|
||||||
|
r.clock = schedT0.Add(time.Minute + startWait)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "could not start the server again: the cluster is at its running-server cap")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the cap clearing lets it start", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
r.a.MaxRunningServers = 1
|
||||||
|
r.cl.list = []ServerInfo{{Name: "other", DesiredState: string(v1alpha1.DesiredRunning)}}
|
||||||
|
r.tick(t)
|
||||||
|
r.cl.list = nil
|
||||||
|
r.clock = schedT0.Add(time.Minute)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleOK, "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleRunnerBackup(t *testing.T) {
|
||||||
|
job := func(state, message string, at time.Time) AsyncJob {
|
||||||
|
return AsyncJob{Kind: "backup", State: state, Message: message, StartedAt: at, Scheduled: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a running server: stop, back up, start", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStopping)
|
||||||
|
r.clock = schedT0.Add(time.Minute)
|
||||||
|
r.cl.maintErr["survival"] = ErrNotStopped
|
||||||
|
r.cl.byName["survival"].DesiredState = string(v1alpha1.DesiredStopped)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStopping) // the pod is still going down
|
||||||
|
delete(r.cl.maintErr, "survival")
|
||||||
|
r.stopped()
|
||||||
|
r.clock = schedT0.Add(2 * time.Minute)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runBackingUp)
|
||||||
|
if strings.Join(r.cl.acquired, ",") != "survival:backup" || strings.Join(r.cl.released, ",") != "survival" ||
|
||||||
|
len(r.b.scheduled) != 1 || r.b.scheduled[0] != (ScheduledCandidate{Name: "survival", OwnerID: "owner1"}) {
|
||||||
|
t.Fatalf("acquired %v released %v backups %v", r.cl.acquired, r.cl.released, r.b.scheduled)
|
||||||
|
}
|
||||||
|
r.jobs.jobs = []AsyncJob{job("running", "", r.clock.Add(-time.Minute))} // clock skew
|
||||||
|
r.clock = schedT0.Add(30 * time.Minute)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runBackingUp)
|
||||||
|
r.jobs.jobs = []AsyncJob{job("succeeded", "", schedT0.Add(time.Minute))}
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleOK, "")
|
||||||
|
if r.cl.desired["survival"] != v1alpha1.DesiredRunning || r.jobs.got != "survival" {
|
||||||
|
t.Fatalf("desired %q, jobs read for %q", r.cl.desired["survival"], r.jobs.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a stopped server is backed up at once and left stopped", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runBackingUp)
|
||||||
|
r.jobs.jobs = []AsyncJob{job("succeeded", "", schedT0)}
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleOK, "")
|
||||||
|
if r.cl.desired["survival"] != "" {
|
||||||
|
t.Fatalf("desired %q, want untouched", r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failed backup is reported and the server still comes back", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
r.tick(t)
|
||||||
|
r.jobs.jobs = []AsyncJob{job("failed", "disk full", schedT0), job("succeeded", "", schedT0.Add(-time.Hour))}
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "the backup failed: disk full")
|
||||||
|
if r.cl.desired["survival"] != v1alpha1.DesiredRunning {
|
||||||
|
t.Fatalf("desired %q", r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an older or unscheduled Job is not this run's", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
manual := job("failed", "x", schedT0)
|
||||||
|
manual.Scheduled = false
|
||||||
|
r.jobs.jobs = []AsyncJob{manual, job("succeeded", "", schedT0.Add(-backupJobSkew-time.Second))}
|
||||||
|
r.clock = schedT0.Add(backupJobSkew - time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runBackingUp)
|
||||||
|
r.clock = schedT0.Add(backupJobSkew)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "the backup Job is gone before it could be checked; see the Backups page")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("two scheduled Jobs since the step: the newest counts", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.jobs.jobs = []AsyncJob{job("succeeded", "", schedT0.Add(time.Minute)), job("failed", "disk full", schedT0)}
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleOK, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a Job running past 45 minutes", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.jobs.jobs = []AsyncJob{job("running", "", schedT0)}
|
||||||
|
r.clock = schedT0.Add(backupWait - time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runBackingUp)
|
||||||
|
r.clock = schedT0.Add(backupWait)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "the backup did not finish within 45 minutes")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a world kept busy for 15 minutes", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
r.cl.maintErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindRestore}
|
||||||
|
r.clock = schedT0.Add(stopWait - time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStopping)
|
||||||
|
r.clock = schedT0.Add(stopWait)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "another operation kept the world busy for 15 minutes; it was started again")
|
||||||
|
if len(r.b.scheduled) != 0 || r.cl.desired["survival"] != v1alpha1.DesiredRunning {
|
||||||
|
t.Fatalf("backups %v, desired %q", r.b.scheduled, r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a pod that does not stop in 15 minutes: no backup, started again", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.cl.byName["survival"].DesiredState = string(v1alpha1.DesiredStopped)
|
||||||
|
r.cl.maintErr["survival"] = ErrNotStopped
|
||||||
|
r.clock = schedT0.Add(stopWait)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "the server did not stop within 15 minutes; it was started again")
|
||||||
|
if len(r.b.scheduled) != 0 {
|
||||||
|
t.Fatalf("backups %v", r.b.scheduled)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failed Job without a message", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.jobs.jobs = []AsyncJob{job("failed", "", schedT0)}
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "the backup failed:")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the backup cannot start", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.b.err = errors.New("quota")
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "could not start the backup: quota; the server was started again")
|
||||||
|
if strings.Join(r.cl.released, ",") != "survival" {
|
||||||
|
t.Fatalf("released %v", r.cl.released)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refused before touching the server", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, result, detail string
|
||||||
|
setup func(r *schedRig)
|
||||||
|
}{
|
||||||
|
{"no backup executor", ScheduleFailed, "backups are not configured", func(r *schedRig) { r.a.Backuper = &fakeBackuper{} }},
|
||||||
|
{"store full", ScheduleFailed, "the backup store is full; ask an administrator to free space", func(r *schedRig) {
|
||||||
|
r.a.BackupStoreCap = 1
|
||||||
|
r.repo.backups = append(r.repo.backups, fakeBackup{view: BackupView{Status: "present", SizeBytes: 1}})
|
||||||
|
}},
|
||||||
|
{"no world yet", ScheduleSkipped, "the server has no world yet", func(r *schedRig) { r.cl.noWorld["survival"] = true }},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
tc.setup(r)
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, tc.result, tc.detail)
|
||||||
|
if r.cl.desired["survival"] != "" || len(r.b.scheduled) != 0 {
|
||||||
|
t.Fatalf("desired %q, backups %v", r.cl.desired["survival"], r.b.scheduled)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleRunnerStaleClaim(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleCommand, schedT0.Add(time.Hour), func(s *Schedule) {
|
||||||
|
s.RunState, s.RunStepAt = runClaimed, ptrTime(schedT0.Add(-claimedStale+time.Second))
|
||||||
|
})
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runClaimed)
|
||||||
|
r.clock = schedT0.Add(time.Second)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "felis-api stopped in the middle of this run")
|
||||||
|
if r.con.calls != 0 {
|
||||||
|
t.Fatal("re-ran the command of a stale claim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripFormatting(t *testing.T) {
|
||||||
|
if got := stripFormatting(" §l§aHi§r §x§§ok "); got != "Hi ok" {
|
||||||
|
t.Fatalf("stripFormatting = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleRunnerEdges(t *testing.T) {
|
||||||
|
t.Run("no store: nothing to run", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.a.Schedules = nil
|
||||||
|
r.tick(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no console: no warning, and a command fails", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.a.Console = nil
|
||||||
|
warn := r.schedule(ScheduleRestart, schedT0.Add(time.Minute), func(s *Schedule) { s.WarnMinutes = 5 })
|
||||||
|
cmd := r.schedule(ScheduleCommand, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, cmd, ScheduleFailed, "the console is not configured")
|
||||||
|
if s := r.st.row(t, warn); s.WarnedFor != nil {
|
||||||
|
t.Fatalf("marked warned without a console: %+v", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a stopped server's backup gives up without starting it", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
r.cl.maintErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindFileWrite}
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runStopping)
|
||||||
|
r.clock = schedT0.Add(stopWait)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "another operation kept the world busy for 15 minutes")
|
||||||
|
if r.cl.desired["survival"] != "" {
|
||||||
|
t.Fatalf("desired %q, want untouched", r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a stopped server's backup that cannot start leaves it stopped", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
r.b.err = errors.New("quota")
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "could not start the backup: quota")
|
||||||
|
if r.cl.desired["survival"] != "" {
|
||||||
|
t.Fatalf("desired %q, want untouched", r.cl.desired["survival"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the backup executor gone mid-run (felis-api restarted without it)", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
r.a.Backuper = &fakeBackuper{}
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "backups are not configured")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the server deleted while its world is being locked", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
r.cl.maintErr["survival"] = ErrNotFound
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleSkipped, "the server no longer exists")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("without Job status the backup counts as done", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.stopped()
|
||||||
|
r.a.JobStatus = nil
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.step(t, id, runBackingUp)
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleOK, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failed backup whose server cannot start either names both", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleBackup, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
r.tick(t)
|
||||||
|
r.repo.byName["survival"].Retire = &RetireState{}
|
||||||
|
r.jobs.jobs = []AsyncJob{{Kind: "backup", State: "failed", Message: "disk full", StartedAt: schedT0, Scheduled: true}}
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "the backup failed: disk full; could not start the server again: the server is being given up or deleted")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a retirement stops the restart at once", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
r.repo.byName["survival"].Retire = &RetireState{}
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleFailed, "could not start the server again: the server is being given up or deleted")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the server deleted before it could start again", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0)
|
||||||
|
r.tick(t)
|
||||||
|
r.stopped()
|
||||||
|
delete(r.repo.byName, "survival")
|
||||||
|
r.tick(t)
|
||||||
|
r.outcome(t, id, ScheduleSkipped, "the server no longer exists")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleNextRunMidnightJump(t *testing.T) {
|
||||||
|
// Chile moves its clocks from 00:00 to 01:00 on 2026-09-06: that Sunday has
|
||||||
|
// no midnight, so the weekday is read at midday.
|
||||||
|
santiago := mustZone(t, "America/Santiago")
|
||||||
|
s := Schedule{MinuteOfDay: 12 * 60, Weekdays: 1, Timezone: "America/Santiago"}
|
||||||
|
after := time.Date(2026, 9, 5, 13, 0, 0, 0, santiago)
|
||||||
|
if got, want := s.nextRun(after), time.Date(2026, 9, 6, 12, 0, 0, 0, santiago); !got.Equal(want) {
|
||||||
|
t.Fatalf("nextRun = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduleFinishLostRace: a finish that another felis-api beat to the row
|
||||||
|
// changes nothing and leaves no second schedule.run audit.
|
||||||
|
func TestScheduleFinishLostRace(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0, func(s *Schedule) { s.LastResult = ScheduleOK })
|
||||||
|
s := r.st.row(t, id)
|
||||||
|
if err := r.a.finishRun(t.Context(), s, runStarting, ScheduleFailed, "late"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.outcome(t, id, ScheduleOK, "")
|
||||||
|
if len(r.repo.audits) != 0 {
|
||||||
|
t.Fatalf("audits %+v", r.repo.audits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
_ "time/tzdata" // a schedule's timezone resolves on a host without zoneinfo
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scheduled tasks. An owner or admin asks felis-api to act on a server at set
|
||||||
|
// times: run a console command, restart it, stop it, start it, or back it up.
|
||||||
|
// A backup of a running server stops it, takes the backup and starts it again,
|
||||||
|
// so a world kept up around the clock still gets restore points; the
|
||||||
|
// BackupScheduler only ever backs up a stopped world. The schedules live in
|
||||||
|
// server_schedules (migration 0036), and RunSchedules (schedulerunner.go) is
|
||||||
|
// the loop that fires them.
|
||||||
|
|
||||||
|
// Schedule actions.
|
||||||
|
const (
|
||||||
|
ScheduleCommand = "command"
|
||||||
|
ScheduleRestart = "restart"
|
||||||
|
ScheduleStop = "stop"
|
||||||
|
ScheduleStart = "start"
|
||||||
|
ScheduleBackup = "backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run results (Schedule.LastResult). A run in progress has none yet.
|
||||||
|
const (
|
||||||
|
ScheduleOK = "ok"
|
||||||
|
ScheduleSkipped = "skipped"
|
||||||
|
ScheduleFailed = "failed"
|
||||||
|
// ScheduleMissed is a run felis-api was not up for: it is dropped once it
|
||||||
|
// is scheduleMissGrace late, so a restart never lands hours after its time.
|
||||||
|
ScheduleMissed = "missed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxSchedulesPerServer bounds the rows one server can hold.
|
||||||
|
maxSchedulesPerServer = 20
|
||||||
|
// maxScheduleLabel bounds a label, in characters.
|
||||||
|
maxScheduleLabel = 64
|
||||||
|
// maxScheduleDetail bounds the stored outcome of a run (a command's reply
|
||||||
|
// can be long), in bytes.
|
||||||
|
maxScheduleDetail = 500
|
||||||
|
// minPowerEveryMinutes is the shortest interval of an action that takes the
|
||||||
|
// server down; a command may run every scheduleEveryMinutes[0].
|
||||||
|
minPowerEveryMinutes = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// scheduleEveryMinutes are the intervals a repeating schedule may use: each
|
||||||
|
// divides a day, so the runs sit at the same clock times every day.
|
||||||
|
scheduleEveryMinutes = []int{15, 30, 60, 120, 180, 240, 360, 480, 720}
|
||||||
|
// scheduleWarnMinutes are the lead times of the in-game warning.
|
||||||
|
scheduleWarnMinutes = []int{0, 1, 5, 10, 15, 30}
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrScheduleLimit means the server already holds maxSchedulesPerServer.
|
||||||
|
ErrScheduleLimit = errors.New("schedule limit reached")
|
||||||
|
// ErrScheduleRunning means the schedule has a run in progress, which must
|
||||||
|
// finish before the schedule is changed, deleted or run again.
|
||||||
|
ErrScheduleRunning = errors.New("schedule is running")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schedule is one server_schedules row as the owner sees it. OwnerID, the
|
||||||
|
// warning marker and the run step stay off the wire; RunState tells the panel
|
||||||
|
// what a run in progress is waiting for.
|
||||||
|
type Schedule struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Server string `json:"server"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
// Command is the console command of a command schedule, without a slash.
|
||||||
|
Command string `json:"command"`
|
||||||
|
// EveryMinutes repeats the schedule at every multiple of it since local
|
||||||
|
// midnight; 0 runs it once a day at MinuteOfDay. Either way only on the
|
||||||
|
// Weekdays (a bitmask, bit 0 Sunday), in Timezone.
|
||||||
|
EveryMinutes int `json:"every_minutes"`
|
||||||
|
MinuteOfDay int `json:"minute_of_day"`
|
||||||
|
Weekdays int `json:"weekdays"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
// WarnMinutes is how long before a restart, stop or backup the players on
|
||||||
|
// the server are told it is coming; 0 says nothing.
|
||||||
|
WarnMinutes int `json:"warn_minutes"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// NextRunAt is nil while the schedule is disabled.
|
||||||
|
NextRunAt *time.Time `json:"next_run_at"`
|
||||||
|
// RunState is the step of a run in progress (runStopping, runBackingUp,
|
||||||
|
// runStarting, or runClaimed while its first step runs); empty otherwise.
|
||||||
|
RunState string `json:"run_state"`
|
||||||
|
LastRunAt *time.Time `json:"last_run_at"`
|
||||||
|
LastResult string `json:"last_result"`
|
||||||
|
LastDetail string `json:"last_detail"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// OwnerID is the server's owner when the schedule was saved (empty for an
|
||||||
|
// unowned server). The runner fires the schedule only while it still is.
|
||||||
|
OwnerID string `json:"-"`
|
||||||
|
WarnedFor *time.Time `json:"-"`
|
||||||
|
RunResume bool `json:"-"`
|
||||||
|
RunStepAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DueSchedule is a schedule the runner has to look at, with the server's
|
||||||
|
// current owner.
|
||||||
|
type DueSchedule struct {
|
||||||
|
Schedule
|
||||||
|
ServerOwner string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerSchedules stores the schedules (PGRepo). The run methods are
|
||||||
|
// compare-and-set writes that report whether they applied, so two felis-api
|
||||||
|
// processes side by side during a rollout never fire one run twice.
|
||||||
|
type ServerSchedules interface {
|
||||||
|
// ListSchedules lists a server's schedules, oldest first.
|
||||||
|
ListSchedules(ctx context.Context, server string) ([]Schedule, error)
|
||||||
|
// GetSchedule reads one schedule of server, or ErrNotFound.
|
||||||
|
GetSchedule(ctx context.Context, server string, id int64) (*Schedule, error)
|
||||||
|
// CreateSchedule inserts s and fills in its ID and CreatedAt, or returns
|
||||||
|
// ErrScheduleLimit when the server already holds limit schedules.
|
||||||
|
CreateSchedule(ctx context.Context, s *Schedule, limit int) error
|
||||||
|
// UpdateSchedule writes s's settings, owner and next run and clears its
|
||||||
|
// warning marker: ErrNotFound, or ErrScheduleRunning during a run.
|
||||||
|
UpdateSchedule(ctx context.Context, s *Schedule) error
|
||||||
|
// DeleteSchedule removes a schedule: ErrNotFound, or ErrScheduleRunning
|
||||||
|
// during a run.
|
||||||
|
DeleteSchedule(ctx context.Context, server string, id int64) error
|
||||||
|
|
||||||
|
// DueSchedules lists the enabled schedules of live servers due by horizon
|
||||||
|
// and every schedule with a run in progress.
|
||||||
|
DueSchedules(ctx context.Context, horizon time.Time) ([]DueSchedule, error)
|
||||||
|
// ClaimScheduleRun starts a run (run state runClaimed, a fresh result) of
|
||||||
|
// a schedule without one. With due set it claims only the enabled run due
|
||||||
|
// then and moves the schedule on to next; nil due is a run on request that
|
||||||
|
// leaves the next run where it is.
|
||||||
|
ClaimScheduleRun(ctx context.Context, id int64, due, next *time.Time, now time.Time) (bool, error)
|
||||||
|
// AdvanceScheduleRun moves a run from step from to step to, recording
|
||||||
|
// resume and, when result is set, the outcome so far.
|
||||||
|
AdvanceScheduleRun(ctx context.Context, id int64, from, to string, resume bool, result, detail string, now time.Time) (bool, error)
|
||||||
|
// FinishScheduleRun ends a run at step from with its outcome.
|
||||||
|
FinishScheduleRun(ctx context.Context, id int64, from, result, detail string) (bool, error)
|
||||||
|
// MissScheduleRun records the run due then as missed and moves on to next.
|
||||||
|
MissScheduleRun(ctx context.Context, id int64, due, next time.Time, detail string) (bool, error)
|
||||||
|
// WarnScheduleRun marks the run due then as warned about, once.
|
||||||
|
WarnScheduleRun(ctx context.Context, id int64, due time.Time) (bool, error)
|
||||||
|
// DisableSchedule turns off an enabled schedule without a run in progress
|
||||||
|
// and records why.
|
||||||
|
DisableSchedule(ctx context.Context, id int64, detail string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleInput is the body of POST /servers/{name}/schedules and of PUT
|
||||||
|
// /servers/{name}/schedules/{id}. Enabled defaults to true.
|
||||||
|
type scheduleInput struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
EveryMinutes int `json:"every_minutes"`
|
||||||
|
MinuteOfDay int `json:"minute_of_day"`
|
||||||
|
Weekdays int `json:"weekdays"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
WarnMinutes int `json:"warn_minutes"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func badSchedule(format string, a ...any) error {
|
||||||
|
return newError(http.StatusBadRequest, "bad_schedule", format, a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply validates in and writes it onto s.
|
||||||
|
func (in *scheduleInput) apply(s *Schedule) error {
|
||||||
|
label := strings.TrimSpace(in.Label)
|
||||||
|
if utf8.RuneCountInString(label) > maxScheduleLabel {
|
||||||
|
return badSchedule("label too long (max %d characters)", maxScheduleLabel)
|
||||||
|
}
|
||||||
|
if strings.IndexFunc(label, func(c rune) bool { return c < 0x20 || c == 0x7f }) >= 0 {
|
||||||
|
return badSchedule("label must be a single line")
|
||||||
|
}
|
||||||
|
|
||||||
|
var command string
|
||||||
|
switch in.Action {
|
||||||
|
case ScheduleCommand:
|
||||||
|
c, err := normalizeConsoleCommand(in.Command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
command = c
|
||||||
|
case ScheduleRestart, ScheduleStop, ScheduleStart, ScheduleBackup:
|
||||||
|
if strings.TrimSpace(in.Command) != "" {
|
||||||
|
return badSchedule("only a command schedule takes a command")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return badSchedule("action must be one of command, restart, stop, start, backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case in.EveryMinutes == 0:
|
||||||
|
if in.MinuteOfDay < 0 || in.MinuteOfDay > 24*60-1 {
|
||||||
|
return badSchedule("minute_of_day must be between 0 and 1439")
|
||||||
|
}
|
||||||
|
case !slices.Contains(scheduleEveryMinutes, in.EveryMinutes):
|
||||||
|
return badSchedule("every_minutes must be 0 or one of 15, 30, 60, 120, 180, 240, 360, 480, 720")
|
||||||
|
case in.EveryMinutes < minPowerEveryMinutes && in.Action != ScheduleCommand:
|
||||||
|
return badSchedule("a %s can repeat at most every %d minutes", in.Action, minPowerEveryMinutes)
|
||||||
|
case in.MinuteOfDay != 0:
|
||||||
|
return badSchedule("a repeating schedule has no minute_of_day")
|
||||||
|
}
|
||||||
|
if in.Weekdays < 1 || in.Weekdays > 0x7f {
|
||||||
|
return badSchedule("weekdays must name at least one day (bits 0 to 6, Sunday first)")
|
||||||
|
}
|
||||||
|
|
||||||
|
tz := strings.TrimSpace(in.Timezone)
|
||||||
|
if tz == "" || tz == "Local" {
|
||||||
|
return badSchedule("timezone must be an IANA zone name such as Asia/Shanghai")
|
||||||
|
}
|
||||||
|
if _, err := time.LoadLocation(tz); err != nil {
|
||||||
|
return badSchedule("unknown timezone %q", tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(scheduleWarnMinutes, in.WarnMinutes) {
|
||||||
|
return badSchedule("warn_minutes must be one of 0, 1, 5, 10, 15, 30")
|
||||||
|
}
|
||||||
|
// A restart, stop or backup repeats at most hourly, so its warning (30
|
||||||
|
// minutes ahead at most) always comes after the run before it.
|
||||||
|
if in.WarnMinutes > 0 && (in.Action == ScheduleCommand || in.Action == ScheduleStart) {
|
||||||
|
return badSchedule("only a restart, stop or backup warns the players")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Label, s.Action, s.Command = label, in.Action, command
|
||||||
|
s.EveryMinutes, s.MinuteOfDay, s.Weekdays = in.EveryMinutes, in.MinuteOfDay, in.Weekdays
|
||||||
|
s.Timezone, s.WarnMinutes = tz, in.WarnMinutes
|
||||||
|
s.Enabled = in.Enabled == nil || *in.Enabled
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextRun is the first run of s strictly after after, or the zero time for a
|
||||||
|
// schedule that names no weekday. The runs are wall-clock times in s's zone:
|
||||||
|
// a time the zone skips at the start of daylight saving runs an hour early
|
||||||
|
// (time.Date reads it with the offset before the jump), and one it repeats
|
||||||
|
// runs once, the first time the clock shows it.
|
||||||
|
func (s *Schedule) nextRun(after time.Time) time.Time {
|
||||||
|
loc, err := time.LoadLocation(s.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC // validated when saved; a zone later dropped runs in UTC
|
||||||
|
}
|
||||||
|
y, m, d := after.In(loc).Date()
|
||||||
|
// Eight days: today's runs may all be past, and a schedule of one weekday
|
||||||
|
// next runs a week from today.
|
||||||
|
for day := d; day <= d+7; day++ {
|
||||||
|
if s.Weekdays&(1<<time.Date(y, m, day, 12, 0, 0, 0, loc).Weekday()) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var minutes []int
|
||||||
|
if s.EveryMinutes > 0 {
|
||||||
|
for at := 0; at < 24*60; at += s.EveryMinutes {
|
||||||
|
minutes = append(minutes, at)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
minutes = []int{s.MinuteOfDay}
|
||||||
|
}
|
||||||
|
for _, at := range minutes {
|
||||||
|
if t := time.Date(y, m, day, at/60, at%60, 0, 0, loc); t.After(after) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeConsoleCommand makes raw one console command: surrounding space
|
||||||
|
// and a single leading slash removed (players type "/say hi"; RCON wants
|
||||||
|
// "say hi"), within maxConsoleCommandLen, and without control characters, so
|
||||||
|
// one request can never smuggle a second command past a newline.
|
||||||
|
func normalizeConsoleCommand(raw string) (string, error) {
|
||||||
|
command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "/"))
|
||||||
|
if command == "" {
|
||||||
|
return "", newError(http.StatusBadRequest, "bad_request", "command is required")
|
||||||
|
}
|
||||||
|
if len(command) > maxConsoleCommandLen {
|
||||||
|
return "", newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"command too long (max %d bytes)", maxConsoleCommandLen)
|
||||||
|
}
|
||||||
|
if strings.IndexFunc(command, func(c rune) bool { return c < 0x20 }) >= 0 {
|
||||||
|
return "", newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"command must be a single line (no control characters)")
|
||||||
|
}
|
||||||
|
return command, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ ServerSchedules = (*PGRepo)(nil)
|
||||||
|
|
||||||
|
// fakeSchedules is an in-memory ServerSchedules with the compare-and-set
|
||||||
|
// conditions of the PGRepo queries (internal/pgint/schedules_test.go runs the
|
||||||
|
// same cases against Postgres). It hands out copies, so a test sees only what
|
||||||
|
// was written through the interface.
|
||||||
|
type fakeSchedules struct {
|
||||||
|
rows map[int64]*Schedule
|
||||||
|
owners map[string]string // server -> its owner now, for DueSchedules
|
||||||
|
nextID int64
|
||||||
|
claims int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeSchedules() *fakeSchedules {
|
||||||
|
return &fakeSchedules{rows: map[int64]*Schedule{}, owners: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSchedule(s *Schedule) *Schedule {
|
||||||
|
c := *s
|
||||||
|
for _, p := range []**time.Time{&c.NextRunAt, &c.WarnedFor, &c.RunStepAt, &c.LastRunAt} {
|
||||||
|
if *p != nil {
|
||||||
|
*p = ptrTime(**p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameTime(p *time.Time, t time.Time) bool { return p != nil && p.Equal(t) }
|
||||||
|
|
||||||
|
// put stores s as it is and returns its id.
|
||||||
|
func (f *fakeSchedules) put(s Schedule) int64 {
|
||||||
|
f.nextID++
|
||||||
|
s.ID = f.nextID
|
||||||
|
f.rows[s.ID] = cloneSchedule(&s)
|
||||||
|
return s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// row reads a schedule back for a test's assertions.
|
||||||
|
func (f *fakeSchedules) row(t *testing.T, id int64) *Schedule {
|
||||||
|
t.Helper()
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("schedule %d is gone", id)
|
||||||
|
}
|
||||||
|
return cloneSchedule(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) ListSchedules(_ context.Context, server string) ([]Schedule, error) {
|
||||||
|
var out []Schedule
|
||||||
|
for _, r := range f.rows {
|
||||||
|
if r.Server == server {
|
||||||
|
out = append(out, *cloneSchedule(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b Schedule) int { return int(a.ID - b.ID) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) GetSchedule(_ context.Context, server string, id int64) (*Schedule, error) {
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
if !ok || r.Server != server {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return cloneSchedule(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) CreateSchedule(_ context.Context, s *Schedule, limit int) error {
|
||||||
|
n := 0
|
||||||
|
for _, r := range f.rows {
|
||||||
|
if r.Server == s.Server {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n >= limit {
|
||||||
|
return ErrScheduleLimit
|
||||||
|
}
|
||||||
|
s.CreatedAt = time.Unix(1_700_000_000, 0)
|
||||||
|
s.ID = f.put(*s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) idle(server string, id int64) (*Schedule, error) {
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
switch {
|
||||||
|
case !ok || r.Server != server:
|
||||||
|
return nil, ErrNotFound
|
||||||
|
case r.RunState != "":
|
||||||
|
return nil, ErrScheduleRunning
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) UpdateSchedule(_ context.Context, s *Schedule) error {
|
||||||
|
r, err := f.idle(s.Server, s.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.OwnerID, r.Label, r.Action, r.Command = s.OwnerID, s.Label, s.Action, s.Command
|
||||||
|
r.EveryMinutes, r.MinuteOfDay, r.Weekdays, r.Timezone = s.EveryMinutes, s.MinuteOfDay, s.Weekdays, s.Timezone
|
||||||
|
r.WarnMinutes, r.Enabled, r.NextRunAt, r.WarnedFor = s.WarnMinutes, s.Enabled, s.NextRunAt, nil
|
||||||
|
if r.NextRunAt != nil {
|
||||||
|
r.NextRunAt = ptrTime(*r.NextRunAt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) DeleteSchedule(_ context.Context, server string, id int64) error {
|
||||||
|
if _, err := f.idle(server, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(f.rows, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) DueSchedules(_ context.Context, horizon time.Time) ([]DueSchedule, error) {
|
||||||
|
var out []DueSchedule
|
||||||
|
for _, r := range f.rows {
|
||||||
|
if r.RunState != "" || (r.Enabled && r.NextRunAt != nil && !r.NextRunAt.After(horizon)) {
|
||||||
|
out = append(out, DueSchedule{Schedule: *cloneSchedule(r), ServerOwner: f.owners[r.Server]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b DueSchedule) int {
|
||||||
|
if (a.RunState == "") != (b.RunState == "") {
|
||||||
|
if a.RunState != "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if a.NextRunAt != nil && b.NextRunAt != nil && !a.NextRunAt.Equal(*b.NextRunAt) {
|
||||||
|
return a.NextRunAt.Compare(*b.NextRunAt)
|
||||||
|
}
|
||||||
|
return int(a.ID - b.ID)
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) ClaimScheduleRun(_ context.Context, id int64, due, next *time.Time, now time.Time) (bool, error) {
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
if !ok || r.RunState != "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if due != nil {
|
||||||
|
if !r.Enabled || !sameTime(r.NextRunAt, *due) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
r.NextRunAt, r.WarnedFor = ptrTime(*next), nil
|
||||||
|
}
|
||||||
|
f.claims++
|
||||||
|
r.RunState, r.RunResume, r.RunStepAt = runClaimed, false, ptrTime(now)
|
||||||
|
r.LastRunAt, r.LastResult, r.LastDetail = ptrTime(now), "", ""
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) AdvanceScheduleRun(_ context.Context, id int64, from, to string, resume bool, result, detail string, now time.Time) (bool, error) {
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
if !ok || r.RunState != from {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
r.RunState, r.RunResume, r.RunStepAt = to, resume, ptrTime(now)
|
||||||
|
if result != "" {
|
||||||
|
r.LastResult, r.LastDetail = result, detail
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) FinishScheduleRun(_ context.Context, id int64, from, result, detail string) (bool, error) {
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
if !ok || from == "" || r.RunState != from {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
r.RunState, r.RunResume, r.RunStepAt = "", false, nil
|
||||||
|
r.LastResult, r.LastDetail = result, detail
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) MissScheduleRun(_ context.Context, id int64, due, next time.Time, detail string) (bool, error) {
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
if !ok || r.RunState != "" || !r.Enabled || !sameTime(r.NextRunAt, due) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
r.NextRunAt, r.WarnedFor = ptrTime(next), nil
|
||||||
|
r.LastRunAt, r.LastResult, r.LastDetail = ptrTime(due), ScheduleMissed, detail
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) WarnScheduleRun(_ context.Context, id int64, due time.Time) (bool, error) {
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
if !ok || r.RunState != "" || !r.Enabled || !sameTime(r.NextRunAt, due) || sameTime(r.WarnedFor, due) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
r.WarnedFor = ptrTime(due)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSchedules) DisableSchedule(_ context.Context, id int64, detail string) (bool, error) {
|
||||||
|
r, ok := f.rows[id]
|
||||||
|
if !ok || r.RunState != "" || !r.Enabled {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
r.Enabled, r.NextRunAt, r.WarnedFor = false, nil, nil
|
||||||
|
r.LastResult, r.LastDetail = ScheduleSkipped, detail
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustZone(t *testing.T, name string) *time.Location {
|
||||||
|
t.Helper()
|
||||||
|
loc, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduleNextRun pins the run times: daily and repeating rules, the
|
||||||
|
// weekday mask read in the schedule's zone, and the two daylight-saving edges.
|
||||||
|
func TestScheduleNextRun(t *testing.T) {
|
||||||
|
sh := mustZone(t, "Asia/Shanghai")
|
||||||
|
ny := mustZone(t, "America/New_York")
|
||||||
|
const everyDay, weekdaysOnly, monday, saturday = 0x7f, 0x3e, 1 << 1, 1 << 6
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
s Schedule
|
||||||
|
after time.Time
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{"daily, later today",
|
||||||
|
Schedule{MinuteOfDay: 4*60 + 30, Weekdays: everyDay, Timezone: "Asia/Shanghai"},
|
||||||
|
time.Date(2026, 9, 28, 1, 0, 0, 0, sh), time.Date(2026, 9, 28, 4, 30, 0, 0, sh)},
|
||||||
|
{"daily, today's run is past",
|
||||||
|
Schedule{MinuteOfDay: 4 * 60, Weekdays: everyDay, Timezone: "Asia/Shanghai"},
|
||||||
|
time.Date(2026, 9, 28, 4, 0, 0, 0, sh), time.Date(2026, 9, 29, 4, 0, 0, 0, sh)},
|
||||||
|
{"weekday mask in the schedule's zone (Sunday 23:00 UTC is Monday in Shanghai)",
|
||||||
|
Schedule{MinuteOfDay: 9 * 60, Weekdays: monday, Timezone: "Asia/Shanghai"},
|
||||||
|
time.Date(2026, 9, 27, 23, 0, 0, 0, time.UTC), time.Date(2026, 9, 28, 9, 0, 0, 0, sh)},
|
||||||
|
{"one weekday, next week, across the month end",
|
||||||
|
Schedule{MinuteOfDay: 9 * 60, Weekdays: monday, Timezone: "Asia/Shanghai"},
|
||||||
|
time.Date(2026, 9, 28, 10, 0, 0, 0, sh), time.Date(2026, 10, 5, 9, 0, 0, 0, sh)},
|
||||||
|
{"interval: the next multiple since midnight",
|
||||||
|
Schedule{EveryMinutes: 180, Weekdays: everyDay, Timezone: "Asia/Shanghai"},
|
||||||
|
time.Date(2026, 9, 28, 7, 10, 0, 0, sh), time.Date(2026, 9, 28, 9, 0, 0, 0, sh)},
|
||||||
|
{"interval skips the days off (Friday night -> Monday 00:00)",
|
||||||
|
Schedule{EveryMinutes: 720, Weekdays: weekdaysOnly, Timezone: "Asia/Shanghai"},
|
||||||
|
time.Date(2026, 10, 2, 12, 0, 0, 0, sh), time.Date(2026, 10, 5, 0, 0, 0, 0, sh)},
|
||||||
|
{"interval, Saturday only, from the week before",
|
||||||
|
Schedule{EveryMinutes: 15, Weekdays: saturday, Timezone: "UTC"},
|
||||||
|
time.Date(2026, 9, 26, 23, 50, 0, 0, time.UTC), time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)},
|
||||||
|
{"a time daylight saving skips runs an hour early",
|
||||||
|
Schedule{MinuteOfDay: 2*60 + 30, Weekdays: everyDay, Timezone: "America/New_York"},
|
||||||
|
time.Date(2026, 3, 8, 0, 0, 0, 0, ny), time.Date(2026, 3, 8, 6, 30, 0, 0, time.UTC)},
|
||||||
|
{"a repeated time runs once: after the first 01:30 comes tomorrow's",
|
||||||
|
Schedule{MinuteOfDay: 90, Weekdays: everyDay, Timezone: "America/New_York"},
|
||||||
|
time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC), time.Date(2026, 11, 2, 6, 30, 0, 0, time.UTC)},
|
||||||
|
{"a zone no longer known runs in UTC",
|
||||||
|
Schedule{MinuteOfDay: 60, Weekdays: everyDay, Timezone: "Gone/Zone"},
|
||||||
|
time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 28, 1, 0, 0, 0, time.UTC)},
|
||||||
|
{"the first 01:30 on the day the clock goes back",
|
||||||
|
Schedule{MinuteOfDay: 90, Weekdays: everyDay, Timezone: "America/New_York"},
|
||||||
|
time.Date(2026, 11, 1, 0, 0, 0, 0, ny), time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC)},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := tc.s.nextRun(tc.after); !got.Equal(tc.want) {
|
||||||
|
t.Fatalf("nextRun(%s) = %s, want %s", tc.after, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduleInputValidation walks the settings apply refuses, each with the
|
||||||
|
// one field that makes it wrong, and what a valid body stores.
|
||||||
|
func TestScheduleInputValidation(t *testing.T) {
|
||||||
|
valid := func() scheduleInput {
|
||||||
|
return scheduleInput{Action: ScheduleRestart, MinuteOfDay: 240, Weekdays: 0x7f, Timezone: "Asia/Shanghai", WarnMinutes: 5}
|
||||||
|
}
|
||||||
|
bad := []struct {
|
||||||
|
name string
|
||||||
|
edit func(*scheduleInput)
|
||||||
|
code string
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
{"label too long", func(in *scheduleInput) { in.Label = strings.Repeat("猫", 65) }, "bad_schedule", "label too long"},
|
||||||
|
{"label with a newline", func(in *scheduleInput) { in.Label = "a\nb" }, "bad_schedule", "single line"},
|
||||||
|
{"label with DEL", func(in *scheduleInput) { in.Label = "a\x7fb" }, "bad_schedule", "single line"},
|
||||||
|
{"unknown action", func(in *scheduleInput) { in.Action = "reboot" }, "bad_schedule", "action must be"},
|
||||||
|
{"command on a restart", func(in *scheduleInput) { in.Command = "say hi" }, "bad_schedule", "only a command schedule"},
|
||||||
|
{"command action without a command", func(in *scheduleInput) { in.Action, in.WarnMinutes = ScheduleCommand, 0 }, "bad_request", "command is required"},
|
||||||
|
{"two commands in one", func(in *scheduleInput) { in.Action, in.WarnMinutes, in.Command = ScheduleCommand, 0, "say a\nop me" }, "bad_request", "single line"},
|
||||||
|
{"minute_of_day negative", func(in *scheduleInput) { in.MinuteOfDay = -1 }, "bad_schedule", "minute_of_day must be"},
|
||||||
|
{"minute_of_day 1440", func(in *scheduleInput) { in.MinuteOfDay = 1440 }, "bad_schedule", "minute_of_day must be"},
|
||||||
|
{"interval off the list", func(in *scheduleInput) { in.EveryMinutes, in.MinuteOfDay = 45, 0 }, "bad_schedule", "every_minutes must be"},
|
||||||
|
{"restart every 30 minutes", func(in *scheduleInput) { in.EveryMinutes, in.MinuteOfDay = 30, 0 }, "bad_schedule", "at most every 60 minutes"},
|
||||||
|
{"interval with a minute_of_day", func(in *scheduleInput) { in.EveryMinutes = 60 }, "bad_schedule", "no minute_of_day"},
|
||||||
|
{"no weekday", func(in *scheduleInput) { in.Weekdays = 0 }, "bad_schedule", "weekdays"},
|
||||||
|
{"weekday bit 7", func(in *scheduleInput) { in.Weekdays = 0x80 }, "bad_schedule", "weekdays"},
|
||||||
|
{"empty timezone", func(in *scheduleInput) { in.Timezone = " " }, "bad_schedule", "IANA zone"},
|
||||||
|
{"Local", func(in *scheduleInput) { in.Timezone = "Local" }, "bad_schedule", "IANA zone"},
|
||||||
|
{"unknown timezone", func(in *scheduleInput) { in.Timezone = "Mars/Olympus" }, "bad_schedule", "unknown timezone"},
|
||||||
|
{"warning off the list", func(in *scheduleInput) { in.WarnMinutes = 2 }, "bad_schedule", "warn_minutes must be"},
|
||||||
|
{"warning on a command", func(in *scheduleInput) { in.Action, in.Command = ScheduleCommand, "say hi" }, "bad_schedule", "only a restart, stop or backup warns"},
|
||||||
|
{"warning on a start", func(in *scheduleInput) { in.Action = ScheduleStart }, "bad_schedule", "only a restart, stop or backup warns"},
|
||||||
|
}
|
||||||
|
for _, tc := range bad {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
in := valid()
|
||||||
|
tc.edit(&in)
|
||||||
|
err := in.apply(&Schedule{})
|
||||||
|
var he *apiError
|
||||||
|
if !errors.As(err, &he) || he.status != http.StatusBadRequest || he.code != tc.code || !strings.Contains(he.msg, tc.msg) {
|
||||||
|
t.Fatalf("apply = %v, want 400 %s containing %q", err, tc.code, tc.msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("valid bodies store trimmed values", func(t *testing.T) {
|
||||||
|
in := scheduleInput{Label: " 每晚 ", Action: ScheduleCommand, Command: " /say 晚安 ", EveryMinutes: 15,
|
||||||
|
Weekdays: 0x41, Timezone: " Europe/Berlin "}
|
||||||
|
var s Schedule
|
||||||
|
if err := in.apply(&s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := Schedule{Label: "每晚", Action: ScheduleCommand, Command: "say 晚安", EveryMinutes: 15,
|
||||||
|
Weekdays: 0x41, Timezone: "Europe/Berlin", Enabled: true}
|
||||||
|
if s != want {
|
||||||
|
t.Fatalf("stored %+v, want %+v", s, want)
|
||||||
|
}
|
||||||
|
off := false
|
||||||
|
in = scheduleInput{Label: strings.Repeat("猫", 64), Action: ScheduleBackup, MinuteOfDay: 1439, Weekdays: 1,
|
||||||
|
Timezone: "UTC", WarnMinutes: 30, Enabled: &off}
|
||||||
|
if err := in.apply(&s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Enabled || s.Command != "" || s.WarnMinutes != 30 || s.MinuteOfDay != 1439 {
|
||||||
|
t.Fatalf("stored %+v", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedRig is a server with a schedule store: survival, owned by owner1,
|
||||||
|
// running, at 2026-09-28 03:00 UTC.
|
||||||
|
type schedRig struct {
|
||||||
|
a *API
|
||||||
|
repo *fakeRepo
|
||||||
|
cl *fakeCluster
|
||||||
|
st *fakeSchedules
|
||||||
|
con *fakeConsole
|
||||||
|
b *fakeScheduledBackuper
|
||||||
|
jobs *fakeJobStatus
|
||||||
|
clock time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedT0 = time.Date(2026, 9, 28, 3, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func newSchedRig(t *testing.T) *schedRig {
|
||||||
|
t.Helper()
|
||||||
|
r := &schedRig{repo: newFakeRepo(), cl: newFakeCluster(), st: newFakeSchedules(), con: &fakeConsole{},
|
||||||
|
b: &fakeScheduledBackuper{}, jobs: &fakeJobStatus{}, clock: schedT0}
|
||||||
|
r.repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
||||||
|
r.cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: string(v1alpha1.PhaseRunning), Ready: true,
|
||||||
|
DesiredState: string(v1alpha1.DesiredRunning)}
|
||||||
|
r.st.owners["survival"] = "owner1"
|
||||||
|
r.a = newTestAPI(r.repo, r.cl)
|
||||||
|
r.a.Now = func() time.Time { return r.clock }
|
||||||
|
r.a.Schedules, r.a.Console, r.a.Backuper, r.a.JobStatus = r.st, r.con, r.b, r.jobs
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *schedRig) stopped() {
|
||||||
|
info := r.cl.byName["survival"]
|
||||||
|
info.Phase, info.Ready, info.DesiredState = string(v1alpha1.PhaseStopped), false, string(v1alpha1.DesiredStopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedule stores a schedule of survival owned by owner1, due at due.
|
||||||
|
func (r *schedRig) schedule(action string, due time.Time, edit ...func(*Schedule)) int64 {
|
||||||
|
s := Schedule{Server: "survival", OwnerID: "owner1", Action: action, MinuteOfDay: due.Hour()*60 + due.Minute(),
|
||||||
|
Weekdays: 0x7f, Timezone: "UTC", Enabled: true, NextRunAt: ptrTime(due), CreatedBy: "[email protected]"}
|
||||||
|
if action == ScheduleCommand {
|
||||||
|
s.Command = "say hi"
|
||||||
|
}
|
||||||
|
for _, e := range edit {
|
||||||
|
e(&s)
|
||||||
|
}
|
||||||
|
return r.st.put(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *schedRig) tick(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if err := r.a.RunSchedules(context.Background()); err != nil {
|
||||||
|
t.Fatalf("RunSchedules: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
schedOwner = &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
|
||||||
|
schedStranger = &Principal{UserID: "other", Email: "[email protected]", Role: "user"}
|
||||||
|
schedAdmin = &Principal{UserID: "adm", Email: "[email protected]", Role: "admin", ViaAdminAccess: true}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *schedRig) do(p *Principal, method, target, body string) *http.Response {
|
||||||
|
r.a.External = staticExternal{p: p}
|
||||||
|
var h map[string]string
|
||||||
|
if body != "" {
|
||||||
|
h = jsonHeader
|
||||||
|
}
|
||||||
|
return do(r.a.ExternalHandler(), method, target, body, h).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSchedule(t *testing.T, res *http.Response) Schedule {
|
||||||
|
t.Helper()
|
||||||
|
var s Schedule
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func schedErrCode(t *testing.T, res *http.Response) string {
|
||||||
|
t.Helper()
|
||||||
|
var raw map[string]map[string]string
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw["error"]["code"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedBase = "/api/v1/servers/survival/schedules"
|
||||||
|
|
||||||
|
// TestScheduleRoutesGate: every schedule route answers 400 for a bad name, 404
|
||||||
|
// for an unknown server, 403 for a stranger and 503 without a store, before it
|
||||||
|
// reads anything else.
|
||||||
|
func TestScheduleRoutesGate(t *testing.T) {
|
||||||
|
body := `{"action":"restart","minute_of_day":240,"weekdays":127,"timezone":"UTC"}`
|
||||||
|
routes := []struct{ method, path, body string }{
|
||||||
|
{"GET", "", ""}, {"POST", "", body}, {"PUT", "/1", body}, {"DELETE", "/1", ""}, {"POST", "/1/run", ""},
|
||||||
|
}
|
||||||
|
for _, rt := range routes {
|
||||||
|
t.Run(rt.method+rt.path, func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.schedule(ScheduleRestart, schedT0.Add(time.Hour))
|
||||||
|
check := func(p *Principal, target string, status int, code string) {
|
||||||
|
t.Helper()
|
||||||
|
res := r.do(p, rt.method, target, rt.body)
|
||||||
|
if res.StatusCode != status || schedErrCode(t, res) != code {
|
||||||
|
t.Fatalf("%s %s = %d, want %d %s", rt.method, target, res.StatusCode, status, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(schedOwner, "/api/v1/servers/Bad_Name/schedules"+rt.path, 400, "bad_name")
|
||||||
|
check(schedOwner, "/api/v1/servers/nope/schedules"+rt.path, 404, "not_found")
|
||||||
|
check(schedStranger, schedBase+rt.path, 403, "forbidden")
|
||||||
|
r.a.Schedules = nil
|
||||||
|
check(schedOwner, schedBase+rt.path, 503, "schedules_unavailable")
|
||||||
|
if len(r.repo.audits) != 0 || r.con.calls != 0 {
|
||||||
|
t.Fatalf("a refused request left audits %+v / %d console calls", r.repo.audits, r.con.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("bad id", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
for _, id := range []string{"0", "-1", "x"} {
|
||||||
|
res := r.do(schedOwner, "DELETE", schedBase+"/"+id, "")
|
||||||
|
if res.StatusCode != 400 || schedErrCode(t, res) != "bad_id" {
|
||||||
|
t.Fatalf("DELETE %s = %d, want 400 bad_id", id, res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleCRUD(t *testing.T) {
|
||||||
|
t.Run("list: empty is [] with the limit", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
res := r.do(schedOwner, "GET", schedBase, "")
|
||||||
|
raw, _ := io.ReadAll(res.Body)
|
||||||
|
if res.StatusCode != 200 || string(raw) != `{"limit":20,"schedules":[],"server":"survival"}`+"\n" {
|
||||||
|
t.Fatalf("GET = %d %s", res.StatusCode, raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("owner creates: next run, owner binding, audit", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t) // 2026-09-28 03:00 UTC = 11:00 in Shanghai
|
||||||
|
res := r.do(schedOwner, "POST", schedBase,
|
||||||
|
`{"label":"夜间重启","action":"restart","minute_of_day":240,"weekdays":127,"timezone":"Asia/Shanghai","warn_minutes":5}`)
|
||||||
|
if res.StatusCode != 201 {
|
||||||
|
t.Fatalf("POST = %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
got := decodeSchedule(t, res)
|
||||||
|
want := time.Date(2026, 9, 28, 20, 0, 0, 0, time.UTC) // 04:00 on the 29th in Shanghai
|
||||||
|
if got.ID != 1 || got.Label != "夜间重启" || !sameTime(got.NextRunAt, want) || !got.Enabled ||
|
||||||
|
got.CreatedBy != "[email protected]" || got.LastRunAt != nil || got.RunState != "" {
|
||||||
|
t.Fatalf("created %+v", got)
|
||||||
|
}
|
||||||
|
if row := r.st.row(t, 1); row.OwnerID != "owner1" {
|
||||||
|
t.Fatalf("stored owner %q, want owner1", row.OwnerID)
|
||||||
|
}
|
||||||
|
if len(r.repo.audits) != 1 || r.repo.audits[0].Action != "schedule.create" ||
|
||||||
|
r.repo.audits[0].Actor != "[email protected]" || r.repo.audits[0].ActorUserID != "owner1" || r.repo.audits[0].ServerName != "survival" ||
|
||||||
|
string(r.repo.audits[0].Payload) != `{"action":"restart","schedule":1}` {
|
||||||
|
t.Fatalf("audits %+v", r.repo.audits)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin creates on an owned server: it belongs to the owner", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
res := r.do(schedAdmin, "POST", schedBase, `{"action":"stop","minute_of_day":0,"weekdays":1,"timezone":"UTC","enabled":false}`)
|
||||||
|
if res.StatusCode != 201 {
|
||||||
|
t.Fatalf("POST = %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
got := decodeSchedule(t, res)
|
||||||
|
if got.NextRunAt != nil || got.Enabled || got.CreatedBy != "[email protected]" {
|
||||||
|
t.Fatalf("created %+v", got)
|
||||||
|
}
|
||||||
|
if row := r.st.row(t, got.ID); row.OwnerID != "owner1" {
|
||||||
|
t.Fatalf("stored owner %q, want owner1", row.OwnerID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bad settings and unknown fields are 400 and store nothing", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
for body, code := range map[string]string{
|
||||||
|
`{"action":"restart","minute_of_day":240,"weekdays":127,"timezone":"Nowhere/City"}`: "bad_schedule",
|
||||||
|
`{"action":"command","weekdays":127,"timezone":"UTC"}`: "bad_request",
|
||||||
|
`{"action":"restart","weekdays":127,"timezone":"UTC","owner_id":"me"}`: "bad_request",
|
||||||
|
} {
|
||||||
|
res := r.do(schedOwner, "POST", schedBase, body)
|
||||||
|
if res.StatusCode != 400 || schedErrCode(t, res) != code {
|
||||||
|
t.Fatalf("POST %s = %d, want 400 %s", body, res.StatusCode, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.st.rows) != 0 || len(r.repo.audits) != 0 {
|
||||||
|
t.Fatalf("stored %d rows, %d audits", len(r.st.rows), len(r.repo.audits))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the 21st is 409 schedule_limit", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
for range maxSchedulesPerServer {
|
||||||
|
r.schedule(ScheduleStop, schedT0.Add(time.Hour))
|
||||||
|
}
|
||||||
|
res := r.do(schedOwner, "POST", schedBase, `{"action":"stop","weekdays":127,"timezone":"UTC"}`)
|
||||||
|
if res.StatusCode != 409 || schedErrCode(t, res) != "schedule_limit" || len(r.st.rows) != maxSchedulesPerServer {
|
||||||
|
t.Fatalf("POST = %d, rows %d", res.StatusCode, len(r.st.rows))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update: new settings, current owner, fresh next run and warning marker", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0.Add(time.Hour), func(s *Schedule) {
|
||||||
|
s.OwnerID, s.WarnedFor = "previous", ptrTime(schedT0.Add(time.Hour))
|
||||||
|
})
|
||||||
|
res := r.do(schedOwner, "PUT", fmt.Sprintf("%s/%d", schedBase, id),
|
||||||
|
`{"action":"command","command":"/save-all","every_minutes":30,"weekdays":127,"timezone":"UTC"}`)
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
t.Fatalf("PUT = %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
got := decodeSchedule(t, res)
|
||||||
|
row := r.st.row(t, id)
|
||||||
|
if got.Action != ScheduleCommand || got.Command != "save-all" || !sameTime(got.NextRunAt, schedT0.Add(30*time.Minute)) ||
|
||||||
|
row.OwnerID != "owner1" || row.WarnedFor != nil || row.Command != "save-all" {
|
||||||
|
t.Fatalf("PUT answered %+v, stored %+v", got, row)
|
||||||
|
}
|
||||||
|
if len(r.repo.audits) != 1 || r.repo.audits[0].Action != "schedule.update" {
|
||||||
|
t.Fatalf("audits %+v", r.repo.audits)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update and delete refuse a running or unknown schedule", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0.Add(time.Hour), func(s *Schedule) { s.RunState = runStopping })
|
||||||
|
body := `{"action":"stop","weekdays":127,"timezone":"UTC"}`
|
||||||
|
for _, c := range []struct{ method, path, body, code string }{
|
||||||
|
{"PUT", fmt.Sprintf("/%d", id), body, "schedule_running"},
|
||||||
|
{"DELETE", fmt.Sprintf("/%d", id), "", "schedule_running"},
|
||||||
|
{"PUT", "/99", body, "not_found"},
|
||||||
|
{"DELETE", "/99", "", "not_found"},
|
||||||
|
} {
|
||||||
|
res := r.do(schedOwner, c.method, schedBase+c.path, c.body)
|
||||||
|
want := 409
|
||||||
|
if c.code == "not_found" {
|
||||||
|
want = 404
|
||||||
|
}
|
||||||
|
if res.StatusCode != want || schedErrCode(t, res) != c.code {
|
||||||
|
t.Fatalf("%s %s = %d, want %d %s", c.method, c.path, res.StatusCode, want, c.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row := r.st.row(t, id); row.Action != ScheduleRestart || len(r.repo.audits) != 0 {
|
||||||
|
t.Fatalf("refused writes changed %+v / audited %+v", row, r.repo.audits)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a schedule of another server is 404", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.repo.byName["creative"] = &ServerRecord{Name: "creative", OwnerID: "owner1"}
|
||||||
|
id := r.st.put(Schedule{Server: "creative", Action: ScheduleStop, Weekdays: 1, Timezone: "UTC"})
|
||||||
|
res := r.do(schedOwner, "DELETE", fmt.Sprintf("%s/%d", schedBase, id), "")
|
||||||
|
if res.StatusCode != 404 || len(r.st.rows) != 1 {
|
||||||
|
t.Fatalf("DELETE = %d, rows %d", res.StatusCode, len(r.st.rows))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delete", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleStop, schedT0.Add(time.Hour))
|
||||||
|
res := r.do(schedOwner, "DELETE", fmt.Sprintf("%s/%d", schedBase, id), "")
|
||||||
|
if res.StatusCode != 204 || len(r.st.rows) != 0 {
|
||||||
|
t.Fatalf("DELETE = %d, rows %d", res.StatusCode, len(r.st.rows))
|
||||||
|
}
|
||||||
|
if len(r.repo.audits) != 1 || r.repo.audits[0].Action != "schedule.delete" ||
|
||||||
|
string(r.repo.audits[0].Payload) != fmt.Sprintf(`{"action":"stop","schedule":%d}`, id) {
|
||||||
|
t.Fatalf("audits %+v", r.repo.audits)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list shows the schedules oldest first", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
a := r.schedule(ScheduleStop, schedT0.Add(2*time.Hour))
|
||||||
|
b := r.schedule(ScheduleStart, schedT0.Add(time.Hour))
|
||||||
|
res := r.do(schedOwner, "GET", schedBase, "")
|
||||||
|
var body struct {
|
||||||
|
Schedules []Schedule `json:"schedules"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(body.Schedules) != 2 || body.Schedules[0].ID != a || body.Schedules[1].ID != b {
|
||||||
|
t.Fatalf("listed %+v", body.Schedules)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduleRunNow: a run on request starts at once, without the warning,
|
||||||
|
// whether or not the schedule is enabled, and leaves its next run alone.
|
||||||
|
func TestScheduleRunNow(t *testing.T) {
|
||||||
|
t.Run("a command runs and the answer carries its outcome", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
r.con.reply = "§aSaved the game"
|
||||||
|
next := schedT0.Add(5 * time.Hour)
|
||||||
|
id := r.schedule(ScheduleCommand, next, func(s *Schedule) { s.Command = "save-all" })
|
||||||
|
res := r.do(schedOwner, "POST", fmt.Sprintf("%s/%d/run", schedBase, id), "")
|
||||||
|
if res.StatusCode != 202 {
|
||||||
|
t.Fatalf("run = %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
got := decodeSchedule(t, res)
|
||||||
|
if got.LastResult != ScheduleOK || got.LastDetail != "Saved the game" || got.RunState != "" ||
|
||||||
|
!sameTime(got.LastRunAt, schedT0) || !sameTime(got.NextRunAt, next) {
|
||||||
|
t.Fatalf("after the run %+v", got)
|
||||||
|
}
|
||||||
|
if r.con.gotCommand != "save-all" || r.con.calls != 1 {
|
||||||
|
t.Fatalf("console ran %q (%d calls)", r.con.gotCommand, r.con.calls)
|
||||||
|
}
|
||||||
|
var actions []string
|
||||||
|
for _, e := range r.repo.audits {
|
||||||
|
actions = append(actions, e.Action+"/"+e.Actor)
|
||||||
|
}
|
||||||
|
if strings.Join(actions, ",") != "schedule.run_now/[email protected],schedule.run/scheduler" {
|
||||||
|
t.Fatalf("audits %v", actions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a disabled restart starts and goes on in the background", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0, func(s *Schedule) { s.Enabled, s.NextRunAt, s.WarnMinutes = false, nil, 5 })
|
||||||
|
res := r.do(schedOwner, "POST", fmt.Sprintf("%s/%d/run", schedBase, id), "")
|
||||||
|
got := decodeSchedule(t, res)
|
||||||
|
if res.StatusCode != 202 || got.RunState != runStopping || got.NextRunAt != nil {
|
||||||
|
t.Fatalf("run = %d %+v", res.StatusCode, got)
|
||||||
|
}
|
||||||
|
if r.cl.desired["survival"] != v1alpha1.DesiredStopped || r.con.calls != 0 {
|
||||||
|
t.Fatalf("desired %q, %d console calls (no warning on request)", r.cl.desired["survival"], r.con.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a running schedule is 409 schedule_running", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleRestart, schedT0, func(s *Schedule) { s.RunState = runStarting })
|
||||||
|
res := r.do(schedOwner, "POST", fmt.Sprintf("%s/%d/run", schedBase, id), "")
|
||||||
|
if res.StatusCode != 409 || schedErrCode(t, res) != "schedule_running" || len(r.repo.audits) != 0 {
|
||||||
|
t.Fatalf("run = %d, audits %+v", res.StatusCode, r.repo.audits)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a schedule of the previous owner is 409 schedule_stale", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
id := r.schedule(ScheduleCommand, schedT0, func(s *Schedule) { s.OwnerID = "previous" })
|
||||||
|
res := r.do(schedAdmin, "POST", fmt.Sprintf("%s/%d/run", schedBase, id), "")
|
||||||
|
if res.StatusCode != 409 || schedErrCode(t, res) != "schedule_stale" || r.con.calls != 0 || r.st.claims != 0 {
|
||||||
|
t.Fatalf("run = %d, console %d, claims %d", res.StatusCode, r.con.calls, r.st.claims)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown schedule is 404", func(t *testing.T) {
|
||||||
|
r := newSchedRig(t)
|
||||||
|
res := r.do(schedOwner, "POST", schedBase+"/7/run", "")
|
||||||
|
if res.StatusCode != 404 {
|
||||||
|
t.Fatalf("run = %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
//go:build pgint
|
||||||
|
|
||||||
|
package pgint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSchedule(server, owner, action string, next *time.Time) *api.Schedule {
|
||||||
|
return &api.Schedule{Server: server, OwnerID: owner, Label: "每晚", Action: action, MinuteOfDay: 240,
|
||||||
|
Weekdays: 0x7f, Timezone: "Asia/Shanghai", WarnMinutes: 5, Enabled: next != nil, NextRunAt: next,
|
||||||
|
CreatedBy: "pgint"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCreateSchedule(t *testing.T, s *api.Schedule) *api.Schedule {
|
||||||
|
t.Helper()
|
||||||
|
if err := repo.CreateSchedule(context.Background(), s, 20); err != nil {
|
||||||
|
t.Fatalf("CreateSchedule(%s on %s): %v", s.Action, s.Server, err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustGetSchedule(t *testing.T, server string, id int64) *api.Schedule {
|
||||||
|
t.Helper()
|
||||||
|
s, err := repo.GetSchedule(context.Background(), server, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSchedule(%d): %v", id, err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// runView is the run columns of a schedule, for exact comparisons.
|
||||||
|
func runView(s *api.Schedule) string {
|
||||||
|
ts := func(p *time.Time) string {
|
||||||
|
if p == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return p.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("state=%q resume=%v step=%s last=%s result=%q detail=%q next=%s warned=%s enabled=%v",
|
||||||
|
s.RunState, s.RunResume, ts(s.RunStepAt), ts(s.LastRunAt), s.LastResult, s.LastDetail,
|
||||||
|
ts(s.NextRunAt), ts(s.WarnedFor), s.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applied(t *testing.T, what string, ok bool, err error, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil || ok != want {
|
||||||
|
t.Fatalf("%s = %v, %v; want applied=%v", what, ok, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduleStoreCRUD pins the settings side of server_schedules: a round
|
||||||
|
// trip of every column, the per-server limit, the run-state guard on changes,
|
||||||
|
// and the lookups scoped to their server.
|
||||||
|
func TestScheduleStoreCRUD(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
u := newUser(t, "user", "sch-crud")
|
||||||
|
sfx := suffix(t)
|
||||||
|
name, other, gone := "sc-"+sfx, "sco-"+sfx, "scg-"+sfx
|
||||||
|
seedOwnedServer(t, name, u.ID, false)
|
||||||
|
seedOwnedServer(t, other, u.ID, false)
|
||||||
|
seedOwnedServer(t, gone, u.ID, true)
|
||||||
|
next := mustNow().Add(time.Hour).Truncate(time.Second)
|
||||||
|
|
||||||
|
s := mustCreateSchedule(t, newSchedule(name, u.ID, api.ScheduleRestart, &next))
|
||||||
|
if s.ID == 0 || time.Since(s.CreatedAt) > time.Minute {
|
||||||
|
t.Fatalf("created %+v", s)
|
||||||
|
}
|
||||||
|
settings := func(s *api.Schedule) string {
|
||||||
|
return fmt.Sprintf("%d %s owner=%q label=%q %s cmd=%q every=%d at=%d days=%d tz=%s warn=%d by=%s created=%s",
|
||||||
|
s.ID, s.Server, s.OwnerID, s.Label, s.Action, s.Command, s.EveryMinutes, s.MinuteOfDay, s.Weekdays,
|
||||||
|
s.Timezone, s.WarnMinutes, s.CreatedBy, s.CreatedAt.UTC().Format(time.RFC3339Nano))
|
||||||
|
}
|
||||||
|
got := mustGetSchedule(t, name, s.ID)
|
||||||
|
if settings(got) != settings(s) {
|
||||||
|
t.Fatalf("read back %s\nwant %s", settings(got), settings(s))
|
||||||
|
}
|
||||||
|
if v, want := runView(got), `state="" resume=false step=- last=- result="" detail="" next=`+next.UTC().Format(time.RFC3339)+` warned=- enabled=true`; v != want {
|
||||||
|
t.Fatalf("read back %s\nwant %s", v, want)
|
||||||
|
}
|
||||||
|
if _, err := repo.GetSchedule(ctx, other, s.ID); !errors.Is(err, api.ErrNotFound) {
|
||||||
|
t.Fatalf("GetSchedule on another server = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unowned := mustCreateSchedule(t, newSchedule(other, "", api.ScheduleStop, nil))
|
||||||
|
if g := mustGetSchedule(t, other, unowned.ID); g.OwnerID != "" || g.Enabled || g.NextRunAt != nil {
|
||||||
|
t.Fatalf("unowned, disabled schedule read back %+v", g)
|
||||||
|
}
|
||||||
|
if err := repo.CreateSchedule(ctx, newSchedule(gone, u.ID, api.ScheduleStop, nil), 20); !errors.Is(err, api.ErrNotFound) {
|
||||||
|
t.Fatalf("CreateSchedule on a deleted server = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := repo.CreateSchedule(ctx, newSchedule("nope-"+sfx, u.ID, api.ScheduleStop, nil), 20); !errors.Is(err, api.ErrNotFound) {
|
||||||
|
t.Fatalf("CreateSchedule on an unknown server = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The limit counts the server's own rows only.
|
||||||
|
mustCreateSchedule(t, newSchedule(name, u.ID, api.ScheduleStop, nil))
|
||||||
|
if err := repo.CreateSchedule(ctx, newSchedule(name, u.ID, api.ScheduleStart, nil), 2); !errors.Is(err, api.ErrScheduleLimit) {
|
||||||
|
t.Fatalf("third schedule under a limit of 2 = %v, want ErrScheduleLimit", err)
|
||||||
|
}
|
||||||
|
if err := repo.CreateSchedule(ctx, newSchedule(other, u.ID, api.ScheduleStart, nil), 2); err != nil {
|
||||||
|
t.Fatalf("second schedule of the other server under a limit of 2 = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update writes the settings and owner and clears the warning marker.
|
||||||
|
if ok, err := repo.WarnScheduleRun(ctx, s.ID, next); err != nil || !ok {
|
||||||
|
t.Fatalf("WarnScheduleRun = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
upd := mustGetSchedule(t, name, s.ID)
|
||||||
|
upd.OwnerID, upd.Label, upd.Action, upd.Command = "", "", api.ScheduleCommand, "say hi"
|
||||||
|
upd.EveryMinutes, upd.MinuteOfDay, upd.Weekdays, upd.Timezone, upd.WarnMinutes = 30, 0, 0x41, "UTC", 0
|
||||||
|
upd.Enabled, upd.NextRunAt = false, nil
|
||||||
|
if err := repo.UpdateSchedule(ctx, upd); err != nil {
|
||||||
|
t.Fatalf("UpdateSchedule: %v", err)
|
||||||
|
}
|
||||||
|
g := mustGetSchedule(t, name, s.ID)
|
||||||
|
if g.OwnerID != "" || g.Label != "" || g.Action != api.ScheduleCommand || g.Command != "say hi" || g.EveryMinutes != 30 ||
|
||||||
|
g.MinuteOfDay != 0 || g.Weekdays != 0x41 || g.Timezone != "UTC" || g.WarnMinutes != 0 || g.Enabled ||
|
||||||
|
g.NextRunAt != nil || g.WarnedFor != nil {
|
||||||
|
t.Fatalf("after update %+v", g)
|
||||||
|
}
|
||||||
|
wrong := *g
|
||||||
|
wrong.Server = other
|
||||||
|
if err := repo.UpdateSchedule(ctx, &wrong); !errors.Is(err, api.ErrNotFound) {
|
||||||
|
t.Fatalf("UpdateSchedule under another server = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A running schedule refuses changes and deletion; an idle one is deleted.
|
||||||
|
now := mustNow().Truncate(time.Second)
|
||||||
|
ok, err := mustClaim(t, s.ID, nil, nil, now)
|
||||||
|
applied(t, "claim", ok, err, true)
|
||||||
|
if err := repo.UpdateSchedule(ctx, g); !errors.Is(err, api.ErrScheduleRunning) {
|
||||||
|
t.Fatalf("UpdateSchedule while running = %v, want ErrScheduleRunning", err)
|
||||||
|
}
|
||||||
|
if err := repo.DeleteSchedule(ctx, name, s.ID); !errors.Is(err, api.ErrScheduleRunning) {
|
||||||
|
t.Fatalf("DeleteSchedule while running = %v, want ErrScheduleRunning", err)
|
||||||
|
}
|
||||||
|
ok, err = repo.FinishScheduleRun(ctx, s.ID, "claimed", api.ScheduleOK, "")
|
||||||
|
applied(t, "finish", ok, err, true)
|
||||||
|
if err := repo.DeleteSchedule(ctx, other, s.ID); !errors.Is(err, api.ErrNotFound) {
|
||||||
|
t.Fatalf("DeleteSchedule under another server = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := repo.DeleteSchedule(ctx, name, s.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteSchedule: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.DeleteSchedule(ctx, name, s.ID); !errors.Is(err, api.ErrNotFound) {
|
||||||
|
t.Fatalf("second DeleteSchedule = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
list, err := repo.ListSchedules(ctx, other)
|
||||||
|
if err != nil || len(list) != 2 || list[0].ID != unowned.ID || list[0].ID >= list[1].ID {
|
||||||
|
t.Fatalf("ListSchedules(other) = %+v, %v; want 2, oldest first", list, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustClaim(t *testing.T, id int64, due, next *time.Time, now time.Time) (bool, error) {
|
||||||
|
t.Helper()
|
||||||
|
return repo.ClaimScheduleRun(context.Background(), id, due, next, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduleStoreRunCAS pins the compare-and-set writes of a run, which
|
||||||
|
// keep two felis-api processes from firing one run twice.
|
||||||
|
func TestScheduleStoreRunCAS(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
u := newUser(t, "user", "sch-cas")
|
||||||
|
name := "scc-" + suffix(t)
|
||||||
|
seedOwnedServer(t, name, u.ID, false)
|
||||||
|
t0 := mustNow().Truncate(time.Second)
|
||||||
|
due, next, later := t0.Add(-time.Minute), t0.Add(23*time.Hour), t0.Add(47*time.Hour)
|
||||||
|
s := mustCreateSchedule(t, newSchedule(name, u.ID, api.ScheduleBackup, &due))
|
||||||
|
ts := func(t time.Time) string { return t.UTC().Format(time.RFC3339) }
|
||||||
|
|
||||||
|
// Warned once for the run due then.
|
||||||
|
ok, err := repo.WarnScheduleRun(ctx, s.ID, next)
|
||||||
|
applied(t, "warn for another due time", ok, err, false)
|
||||||
|
ok, err = repo.WarnScheduleRun(ctx, s.ID, due)
|
||||||
|
applied(t, "warn", ok, err, true)
|
||||||
|
ok, err = repo.WarnScheduleRun(ctx, s.ID, due)
|
||||||
|
applied(t, "second warn", ok, err, false)
|
||||||
|
|
||||||
|
// A claim for another due time loses; the right one wins once.
|
||||||
|
ok, err = mustClaim(t, s.ID, &next, &later, t0)
|
||||||
|
applied(t, "claim a stale due time", ok, err, false)
|
||||||
|
ok, err = mustClaim(t, s.ID, &due, &next, t0)
|
||||||
|
applied(t, "claim", ok, err, true)
|
||||||
|
ok, err = mustClaim(t, s.ID, &due, &next, t0)
|
||||||
|
applied(t, "second claim", ok, err, false)
|
||||||
|
ok, err = mustClaim(t, s.ID, nil, nil, t0)
|
||||||
|
applied(t, "claim on request during a run", ok, err, false)
|
||||||
|
if got, want := runView(mustGetSchedule(t, name, s.ID)),
|
||||||
|
fmt.Sprintf(`state="claimed" resume=false step=%s last=%s result="" detail="" next=%s warned=- enabled=true`, ts(t0), ts(t0), ts(next)); got != want {
|
||||||
|
t.Fatalf("after claim %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance from the wrong step loses; the right one keeps the outcome so far
|
||||||
|
// unless it brings one.
|
||||||
|
t1 := t0.Add(time.Minute)
|
||||||
|
ok, err = repo.AdvanceScheduleRun(ctx, s.ID, "stopping", "backing_up", true, "", "", t1)
|
||||||
|
applied(t, "advance from the wrong step", ok, err, false)
|
||||||
|
ok, err = repo.AdvanceScheduleRun(ctx, s.ID, "claimed", "stopping", true, "", "", t1)
|
||||||
|
applied(t, "advance", ok, err, true)
|
||||||
|
t2 := t1.Add(time.Minute)
|
||||||
|
ok, err = repo.AdvanceScheduleRun(ctx, s.ID, "stopping", "starting", true, api.ScheduleFailed, "the backup failed: x", t2)
|
||||||
|
applied(t, "advance with an outcome", ok, err, true)
|
||||||
|
t3 := t2.Add(time.Minute)
|
||||||
|
ok, err = repo.AdvanceScheduleRun(ctx, s.ID, "starting", "starting", true, "", "", t3)
|
||||||
|
applied(t, "advance without one", ok, err, true)
|
||||||
|
if got, want := runView(mustGetSchedule(t, name, s.ID)),
|
||||||
|
fmt.Sprintf(`state="starting" resume=true step=%s last=%s result="failed" detail="the backup failed: x" next=%s warned=- enabled=true`, ts(t3), ts(t0), ts(next)); got != want {
|
||||||
|
t.Fatalf("after advancing %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE server_schedules SET run_step_at = NULL WHERE id = $1`, s.ID); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "check") {
|
||||||
|
t.Fatalf("a run step without its time = %v, want a check violation", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish from the wrong step, or from none, loses.
|
||||||
|
ok, err = repo.FinishScheduleRun(ctx, s.ID, "stopping", api.ScheduleOK, "")
|
||||||
|
applied(t, "finish from the wrong step", ok, err, false)
|
||||||
|
ok, err = repo.FinishScheduleRun(ctx, s.ID, "starting", api.ScheduleFailed, "done")
|
||||||
|
applied(t, "finish", ok, err, true)
|
||||||
|
ok, err = repo.FinishScheduleRun(ctx, s.ID, "", api.ScheduleOK, "")
|
||||||
|
applied(t, "finish an idle schedule", ok, err, false)
|
||||||
|
if got, want := runView(mustGetSchedule(t, name, s.ID)),
|
||||||
|
fmt.Sprintf(`state="" resume=false step=- last=%s result="failed" detail="done" next=%s warned=- enabled=true`, ts(t0), ts(next)); got != want {
|
||||||
|
t.Fatalf("after finishing %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run on request leaves the next run alone and works while disabled.
|
||||||
|
ok, err = mustClaim(t, s.ID, nil, nil, t3)
|
||||||
|
applied(t, "claim on request", ok, err, true)
|
||||||
|
if g := mustGetSchedule(t, name, s.ID); g.NextRunAt == nil || !g.NextRunAt.Equal(next) || g.RunState != "claimed" || g.LastResult != "" {
|
||||||
|
t.Fatalf("after a claim on request %s", runView(g))
|
||||||
|
}
|
||||||
|
ok, err = repo.DisableSchedule(ctx, s.ID, "x")
|
||||||
|
applied(t, "disable during a run", ok, err, false)
|
||||||
|
ok, err = repo.WarnScheduleRun(ctx, s.ID, next)
|
||||||
|
applied(t, "warn during a run", ok, err, false)
|
||||||
|
ok, err = mustClaim(t, s.ID, &next, &later, t3)
|
||||||
|
applied(t, "claim the due run during a run on request", ok, err, false)
|
||||||
|
ok, err = repo.MissScheduleRun(ctx, s.ID, next, later, "x")
|
||||||
|
applied(t, "miss during a run", ok, err, false)
|
||||||
|
ok, err = repo.FinishScheduleRun(ctx, s.ID, "claimed", api.ScheduleOK, "")
|
||||||
|
applied(t, "finish the run on request", ok, err, true)
|
||||||
|
|
||||||
|
// Missed: only the run due then, recorded at its due time.
|
||||||
|
ok, err = repo.MissScheduleRun(ctx, s.ID, due, later, "x")
|
||||||
|
applied(t, "miss a stale due time", ok, err, false)
|
||||||
|
ok, err = repo.WarnScheduleRun(ctx, s.ID, next)
|
||||||
|
applied(t, "warn the next run", ok, err, true)
|
||||||
|
ok, err = repo.MissScheduleRun(ctx, s.ID, next, later, "felis-api was down")
|
||||||
|
applied(t, "miss", ok, err, true)
|
||||||
|
if got, want := runView(mustGetSchedule(t, name, s.ID)),
|
||||||
|
fmt.Sprintf(`state="" resume=false step=- last=%s result="missed" detail="felis-api was down" next=%s warned=- enabled=true`, ts(next), ts(later)); got != want {
|
||||||
|
t.Fatalf("after a miss %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled: once, and a disabled schedule is neither claimed nor missed.
|
||||||
|
ok, err = repo.DisableSchedule(ctx, s.ID, "new owner")
|
||||||
|
applied(t, "disable", ok, err, true)
|
||||||
|
ok, err = repo.DisableSchedule(ctx, s.ID, "again")
|
||||||
|
applied(t, "second disable", ok, err, false)
|
||||||
|
if got, want := runView(mustGetSchedule(t, name, s.ID)),
|
||||||
|
fmt.Sprintf(`state="" resume=false step=- last=%s result="skipped" detail="new owner" next=- warned=- enabled=false`, ts(next)); got != want {
|
||||||
|
t.Fatalf("after disabling %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE server_schedules SET next_run_at = $2 WHERE id = $1`, s.ID, later); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ok, err = mustClaim(t, s.ID, &later, &later, t3)
|
||||||
|
applied(t, "claim a disabled schedule's due run", ok, err, false)
|
||||||
|
ok, err = repo.MissScheduleRun(ctx, s.ID, later, later, "x")
|
||||||
|
applied(t, "miss a disabled schedule's run", ok, err, false)
|
||||||
|
ok, err = repo.WarnScheduleRun(ctx, s.ID, later)
|
||||||
|
applied(t, "warn a disabled schedule's run", ok, err, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDueSchedules pins what the runner reads: enabled schedules of live
|
||||||
|
// servers due by the horizon, every run in progress, runs first, with the
|
||||||
|
// server's owner now.
|
||||||
|
func TestDueSchedules(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
u := newUser(t, "user", "sch-due")
|
||||||
|
v := newUser(t, "user", "sch-due2")
|
||||||
|
sfx := suffix(t)
|
||||||
|
live, gone, unowned := "sdl-"+sfx, "sdg-"+sfx, "sdu-"+sfx
|
||||||
|
seedOwnedServer(t, live, v.ID, false)
|
||||||
|
seedOwnedServer(t, gone, u.ID, true)
|
||||||
|
mustExec(t, `INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb) VALUES ($1, 100, 128, 1)`, unowned)
|
||||||
|
t0 := mustNow().Truncate(time.Second)
|
||||||
|
at := func(m int) *time.Time { p := t0.Add(time.Duration(m) * time.Minute); return &p }
|
||||||
|
horizon := *at(30)
|
||||||
|
|
||||||
|
late := mustCreateSchedule(t, newSchedule(live, u.ID, api.ScheduleStop, at(20)))
|
||||||
|
early := mustCreateSchedule(t, newSchedule(live, u.ID, api.ScheduleStart, at(-5)))
|
||||||
|
edge := mustCreateSchedule(t, newSchedule(unowned, "", api.ScheduleStop, at(30)))
|
||||||
|
mustCreateSchedule(t, newSchedule(live, u.ID, api.ScheduleStop, at(31))) // beyond the horizon
|
||||||
|
off := mustCreateSchedule(t, newSchedule(live, u.ID, api.ScheduleStop, nil)) // disabled, yet due
|
||||||
|
mustExec(t, `UPDATE server_schedules SET next_run_at = $2 WHERE id = $1`, off.ID, *at(-2))
|
||||||
|
running := mustCreateSchedule(t, newSchedule(live, u.ID, api.ScheduleRestart, nil)) // disabled, but running
|
||||||
|
ok, err := mustClaim(t, running.ID, nil, nil, t0)
|
||||||
|
applied(t, "claim", ok, err, true)
|
||||||
|
// Deleted server: its schedules wait for SeedServer or the row's end.
|
||||||
|
mustExec(t, `INSERT INTO server_schedules (server_name, owner_id, action, timezone, next_run_at, created_by)
|
||||||
|
VALUES ($1, $2, 'stop', 'UTC', $3, 'pgint')`, gone, u.ID, *at(-1))
|
||||||
|
|
||||||
|
due, err := repo.DueSchedules(ctx, horizon)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, d := range due {
|
||||||
|
if d.Server != live && d.Server != unowned && d.Server != gone {
|
||||||
|
continue // other tests' rows
|
||||||
|
}
|
||||||
|
got = append(got, fmt.Sprintf("%d/%s/%v", d.ID, d.RunState, d.ServerOwner == v.ID))
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
fmt.Sprintf("%d/claimed/true", running.ID),
|
||||||
|
fmt.Sprintf("%d//true", early.ID),
|
||||||
|
fmt.Sprintf("%d//true", late.ID),
|
||||||
|
fmt.Sprintf("%d//false", edge.ID),
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("due %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for _, d := range due {
|
||||||
|
if d.ID == late.ID && d.OwnerID != u.ID {
|
||||||
|
t.Fatalf("schedule owner %q, want %q", d.OwnerID, u.ID)
|
||||||
|
}
|
||||||
|
if d.ID == edge.ID && d.ServerOwner != "" {
|
||||||
|
t.Fatalf("unowned server's owner %q", d.ServerOwner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchedulesFollowTheServer: a recreated server of the same name starts
|
||||||
|
// without the earlier one's schedules, and an account migration hands the
|
||||||
|
// migrated servers' schedules to the target.
|
||||||
|
func TestSchedulesFollowTheServer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
u := newUser(t, "user", "sch-seed")
|
||||||
|
name, sub := "ss-"+suffix(t), "sss-"+suffix(t)
|
||||||
|
if err := repo.SeedServer(ctx, name, sub, 100, 128, 1024); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := mustCreateSchedule(t, newSchedule(name, u.ID, api.ScheduleStop, nil))
|
||||||
|
if err := repo.SeedServer(ctx, name, sub, 100, 128, 1024); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := repo.GetSchedule(ctx, name, s.ID); !errors.Is(err, api.ErrNotFound) {
|
||||||
|
t.Fatalf("schedule of the earlier server = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src := newUser(t, "user", "sch-src")
|
||||||
|
dst := newUser(t, "user", "sch-dst")
|
||||||
|
other := newUser(t, "user", "sch-other")
|
||||||
|
sfx := suffix(t)
|
||||||
|
mine, theirs := "sm-"+sfx, "st-"+sfx
|
||||||
|
seedOwnedServer(t, mine, src.ID, false)
|
||||||
|
seedOwnedServer(t, theirs, other.ID, false)
|
||||||
|
moved := mustCreateSchedule(t, newSchedule(mine, src.ID, api.ScheduleStop, nil))
|
||||||
|
stale := mustCreateSchedule(t, newSchedule(mine, other.ID, api.ScheduleStop, nil)) // saved by a previous owner
|
||||||
|
kept := mustCreateSchedule(t, newSchedule(theirs, src.ID, api.ScheduleStop, nil)) // src's, on a server src lost
|
||||||
|
t0 := mustNow().Truncate(time.Second)
|
||||||
|
if err := repo.StartMigration(ctx, "schmig-"+sfx, src.ID, t0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess", t0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "sess", "h-sch-"+sfx, t0, t0.Add(10*time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := repo.RedeemMigration(ctx, dst.ID, "h-sch-"+sfx, t0); err != nil {
|
||||||
|
t.Fatalf("RedeemMigration: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
server string
|
||||||
|
id int64
|
||||||
|
owner string
|
||||||
|
}{{mine, moved.ID, dst.ID}, {mine, stale.ID, other.ID}, {theirs, kept.ID, src.ID}} {
|
||||||
|
if g := mustGetSchedule(t, c.server, c.id); g.OwnerID != c.owner {
|
||||||
|
t.Fatalf("schedule %d owner %q, want %q", c.id, g.OwnerID, c.owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Scheduled tasks (GET/POST /servers/{name}/schedules): an owner or admin asks
|
||||||
|
-- felis-api to run a console command, restart, stop, start or back up a server
|
||||||
|
-- at set times. felis-api's schedule runner reads this table every few seconds.
|
||||||
|
--
|
||||||
|
-- A schedule fires at minute_of_day (local to timezone) on the weekdays in the
|
||||||
|
-- bitmask (bit 0 Sunday), or, with every_minutes set, at every multiple of it
|
||||||
|
-- since local midnight on those days. owner_id is the server's owner when the
|
||||||
|
-- schedule was saved (NULL for an unowned server): the runner skips and disables
|
||||||
|
-- a schedule once the server has another owner, so a new owner never inherits
|
||||||
|
-- somebody else's commands. A recreated server of the same name starts without
|
||||||
|
-- schedules (SeedServer deletes them).
|
||||||
|
--
|
||||||
|
-- run_state is the step of a run still in progress (a restart waits for the
|
||||||
|
-- server to stop before starting it, a backup of a running server stops it,
|
||||||
|
-- backs it up and starts it again); run_step_at is when that step began, and
|
||||||
|
-- run_resume says the run starts the server again once its backup is done.
|
||||||
|
CREATE TABLE server_schedules (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
server_name text NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
|
||||||
|
owner_id text REFERENCES users(id),
|
||||||
|
label text NOT NULL DEFAULT '',
|
||||||
|
action text NOT NULL CHECK (action IN ('command', 'restart', 'stop', 'start', 'backup')),
|
||||||
|
command text NOT NULL DEFAULT '',
|
||||||
|
every_minutes integer NOT NULL DEFAULT 0,
|
||||||
|
minute_of_day integer NOT NULL DEFAULT 0 CHECK (minute_of_day BETWEEN 0 AND 1439),
|
||||||
|
weekdays smallint NOT NULL DEFAULT 127 CHECK (weekdays BETWEEN 1 AND 127),
|
||||||
|
timezone text NOT NULL,
|
||||||
|
warn_minutes integer NOT NULL DEFAULT 0,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
next_run_at timestamptz,
|
||||||
|
warned_for timestamptz,
|
||||||
|
run_state text NOT NULL DEFAULT '',
|
||||||
|
run_resume boolean NOT NULL DEFAULT false,
|
||||||
|
run_step_at timestamptz,
|
||||||
|
last_run_at timestamptz,
|
||||||
|
last_result text NOT NULL DEFAULT '',
|
||||||
|
last_detail text NOT NULL DEFAULT '',
|
||||||
|
created_by text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
-- Every write that starts a step stamps it; the runner times each step from it.
|
||||||
|
CHECK (run_state = '' OR run_step_at IS NOT NULL)
|
||||||
|
);
|
||||||
|
CREATE INDEX server_schedules_server ON server_schedules (server_name, id);
|
||||||
|
CREATE INDEX server_schedules_due ON server_schedules (next_run_at) WHERE enabled;
|
||||||
|
CREATE INDEX server_schedules_running ON server_schedules (id) WHERE run_state <> '';
|
||||||
@@ -34,6 +34,9 @@ const ServerBackups = lazyWithReload(() =>
|
|||||||
const ServerFiles = lazyWithReload(() =>
|
const ServerFiles = lazyWithReload(() =>
|
||||||
import("@/pages/ServerFiles").then((m) => ({ default: m.ServerFiles })),
|
import("@/pages/ServerFiles").then((m) => ({ default: m.ServerFiles })),
|
||||||
);
|
);
|
||||||
|
const ServerSchedules = lazyWithReload(() =>
|
||||||
|
import("@/pages/ServerSchedules").then((m) => ({ default: m.ServerSchedules })),
|
||||||
|
);
|
||||||
const ServerLuckPerms = lazyWithReload(() =>
|
const ServerLuckPerms = lazyWithReload(() =>
|
||||||
import("@/pages/ServerLuckPerms").then((m) => ({ default: m.ServerLuckPerms })),
|
import("@/pages/ServerLuckPerms").then((m) => ({ default: m.ServerLuckPerms })),
|
||||||
);
|
);
|
||||||
@@ -107,6 +110,7 @@ export default function App() {
|
|||||||
<Route path="players" element={<ServerPlayers />} />
|
<Route path="players" element={<ServerPlayers />} />
|
||||||
<Route path="backups" element={<ServerBackups />} />
|
<Route path="backups" element={<ServerBackups />} />
|
||||||
<Route path="files" element={<ServerFiles />} />
|
<Route path="files" element={<ServerFiles />} />
|
||||||
|
<Route path="schedules" element={<ServerSchedules />} />
|
||||||
<Route path="luckperms" element={<ServerLuckPerms />} />
|
<Route path="luckperms" element={<ServerLuckPerms />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="submissions" element={<MySubmissionsPage />} />
|
<Route path="submissions" element={<MySubmissionsPage />} />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import enNavigation from "./resources/en-US/navigation.json";
|
|||||||
import enBackups from "./resources/en-US/backups.json";
|
import enBackups from "./resources/en-US/backups.json";
|
||||||
import enSubmissions from "./resources/en-US/submissions.json";
|
import enSubmissions from "./resources/en-US/submissions.json";
|
||||||
import enFiles from "./resources/en-US/files.json";
|
import enFiles from "./resources/en-US/files.json";
|
||||||
|
import enSchedules from "./resources/en-US/schedules.json";
|
||||||
import zhCommon from "./resources/zh-CN/common.json";
|
import zhCommon from "./resources/zh-CN/common.json";
|
||||||
import zhAuth from "./resources/zh-CN/auth.json";
|
import zhAuth from "./resources/zh-CN/auth.json";
|
||||||
import zhDashboard from "./resources/zh-CN/dashboard.json";
|
import zhDashboard from "./resources/zh-CN/dashboard.json";
|
||||||
@@ -25,6 +26,7 @@ import zhNavigation from "./resources/zh-CN/navigation.json";
|
|||||||
import zhBackups from "./resources/zh-CN/backups.json";
|
import zhBackups from "./resources/zh-CN/backups.json";
|
||||||
import zhSubmissions from "./resources/zh-CN/submissions.json";
|
import zhSubmissions from "./resources/zh-CN/submissions.json";
|
||||||
import zhFiles from "./resources/zh-CN/files.json";
|
import zhFiles from "./resources/zh-CN/files.json";
|
||||||
|
import zhSchedules from "./resources/zh-CN/schedules.json";
|
||||||
import { panelLanguage, SUPPORTED_LANGUAGES } from "./language";
|
import { panelLanguage, SUPPORTED_LANGUAGES } from "./language";
|
||||||
|
|
||||||
// Exported so a test can boot a fresh instance with the same detection.
|
// Exported so a test can boot a fresh instance with the same detection.
|
||||||
@@ -43,6 +45,7 @@ export const i18nOptions: InitOptions = {
|
|||||||
backups: enBackups,
|
backups: enBackups,
|
||||||
submissions: enSubmissions,
|
submissions: enSubmissions,
|
||||||
files: enFiles,
|
files: enFiles,
|
||||||
|
schedules: enSchedules,
|
||||||
},
|
},
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
common: zhCommon,
|
common: zhCommon,
|
||||||
@@ -57,6 +60,7 @@ export const i18nOptions: InitOptions = {
|
|||||||
backups: zhBackups,
|
backups: zhBackups,
|
||||||
submissions: zhSubmissions,
|
submissions: zhSubmissions,
|
||||||
files: zhFiles,
|
files: zhFiles,
|
||||||
|
schedules: zhSchedules,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
supportedLngs: [...SUPPORTED_LANGUAGES],
|
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||||
|
|||||||
@@ -118,5 +118,10 @@
|
|||||||
"bad_request": "The request was not accepted: {{detail}}",
|
"bad_request": "The request was not accepted: {{detail}}",
|
||||||
"restore_in_progress": "Another backup is still being restored to this server's world. Try again once it finishes.",
|
"restore_in_progress": "Another backup is still being restored to this server's world. Try again once it finishes.",
|
||||||
"uploads_full": "The upload store is full. An admin has to delete reviewed submissions before new uploads fit.",
|
"uploads_full": "The upload store is full. An admin has to delete reviewed submissions before new uploads fit.",
|
||||||
"unsupported_media_type": "The request was sent in a format the server does not accept. Reload the page and try again."
|
"unsupported_media_type": "The request was sent in a format the server does not accept. Reload the page and try again.",
|
||||||
|
"schedules_unavailable": "Scheduled tasks aren't available right now.",
|
||||||
|
"schedule_limit": "This server already has as many scheduled tasks as it can hold. Delete one first.",
|
||||||
|
"schedule_running": "This task is running right now. Try again once the run finishes.",
|
||||||
|
"schedule_stale": "The server has a new owner since this task was saved. Save the task again before running it.",
|
||||||
|
"bad_schedule": "The task was not saved: {{detail}}"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
{
|
||||||
|
"title": "Scheduled tasks",
|
||||||
|
"subtitle": "Restart, stop, start or back up this server, or send its console a command, at set times.",
|
||||||
|
"back_to_console": "Back to console",
|
||||||
|
"not_yours_title": "No permission to manage scheduled tasks",
|
||||||
|
"not_yours_body": "Only the owner or an admin can view and change this server's scheduled tasks.",
|
||||||
|
"new_task": "New task",
|
||||||
|
"count": "{{count}} of {{limit}} tasks",
|
||||||
|
"limit_reached": "A server holds at most {{limit}} tasks. Delete one to add another.",
|
||||||
|
"unavailable_title": "Scheduled tasks aren't available",
|
||||||
|
"unavailable_body": "This felis-api runs without a schedule store, so tasks can't be listed or saved here. Ask an administrator to check the felis-api setup.",
|
||||||
|
"empty_title": "No scheduled tasks yet",
|
||||||
|
"empty_hint": "Start from a common one, or build your own with New task.",
|
||||||
|
"template_restart": "Restart every night at 04:00",
|
||||||
|
"template_backup": "Back up every night at 05:00",
|
||||||
|
"template_announce": "Announce in chat every 30 minutes",
|
||||||
|
"template_announce_command": "say Welcome to the server!",
|
||||||
|
"action_command": "Console command",
|
||||||
|
"action_command_desc": "Send one command to the server console, as if typed there. Runs only while the server is running.",
|
||||||
|
"action_restart": "Restart",
|
||||||
|
"action_restart_desc": "Stop the server and start it again. Online players are disconnected. Skipped while it is stopped.",
|
||||||
|
"action_stop": "Stop",
|
||||||
|
"action_stop_desc": "Stop the server. Online players are disconnected.",
|
||||||
|
"action_start": "Start",
|
||||||
|
"action_start_desc": "Start the server when it is stopped.",
|
||||||
|
"action_backup": "Backup",
|
||||||
|
"action_backup_desc": "Back up the world. A running server is stopped first and started again afterwards.",
|
||||||
|
"days_all": "Every day",
|
||||||
|
"days_weekdays": "Weekdays",
|
||||||
|
"days_weekend": "Weekends",
|
||||||
|
"day_sep": ", ",
|
||||||
|
"day_0": "Sun",
|
||||||
|
"day_1": "Mon",
|
||||||
|
"day_2": "Tue",
|
||||||
|
"day_3": "Wed",
|
||||||
|
"day_4": "Thu",
|
||||||
|
"day_5": "Fri",
|
||||||
|
"day_6": "Sat",
|
||||||
|
"day_chip_0": "Sun",
|
||||||
|
"day_chip_1": "Mon",
|
||||||
|
"day_chip_2": "Tue",
|
||||||
|
"day_chip_3": "Wed",
|
||||||
|
"day_chip_4": "Thu",
|
||||||
|
"day_chip_5": "Fri",
|
||||||
|
"day_chip_6": "Sat",
|
||||||
|
"day_full_0": "Sunday",
|
||||||
|
"day_full_1": "Monday",
|
||||||
|
"day_full_2": "Tuesday",
|
||||||
|
"day_full_3": "Wednesday",
|
||||||
|
"day_full_4": "Thursday",
|
||||||
|
"day_full_5": "Friday",
|
||||||
|
"day_full_6": "Saturday",
|
||||||
|
"first_day": "0",
|
||||||
|
"when_daily": "{{days}} at {{time}}",
|
||||||
|
"when_interval_days": "{{every}} · {{days}}",
|
||||||
|
"every_minutes_one": "Every minute",
|
||||||
|
"every_minutes_other": "Every {{count}} minutes",
|
||||||
|
"every_hours_one": "Every hour",
|
||||||
|
"every_hours_other": "Every {{count}} hours",
|
||||||
|
"opt_minutes_one": "{{count}} minute",
|
||||||
|
"opt_minutes_other": "{{count}} minutes",
|
||||||
|
"opt_hours_one": "{{count}} hour",
|
||||||
|
"opt_hours_other": "{{count}} hours",
|
||||||
|
"create_title": "New scheduled task",
|
||||||
|
"edit_title": "Edit scheduled task",
|
||||||
|
"dialog_desc": "Choose what runs and when.",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save",
|
||||||
|
"field_action": "What to do",
|
||||||
|
"field_command": "Command",
|
||||||
|
"command_placeholder": "say The server restarts at 04:00",
|
||||||
|
"command_hint": "One console command. The leading / is optional.",
|
||||||
|
"field_when": "When",
|
||||||
|
"mode_daily": "Once a day",
|
||||||
|
"mode_interval": "Repeat",
|
||||||
|
"field_time": "Time",
|
||||||
|
"field_every": "Repeat every",
|
||||||
|
"interval_preview": "Counted from 00:00 each day: {{times}}",
|
||||||
|
"interval_hourly_limit": "A restart, stop, start or backup repeats at most once an hour.",
|
||||||
|
"field_days": "Days",
|
||||||
|
"field_timezone": "Time zone",
|
||||||
|
"timezone_hint": "Times are read in this zone. Your browser is in {{zone}}.",
|
||||||
|
"timezone_use_browser": "Use the browser's zone",
|
||||||
|
"field_warn": "Warn players",
|
||||||
|
"warn_none": "No warning",
|
||||||
|
"warn_option_one": "{{count}} minute before",
|
||||||
|
"warn_option_other": "{{count}} minutes before",
|
||||||
|
"warn_hint": "Posts a message in game chat ahead of the stop, in Chinese and English, while the server is running.",
|
||||||
|
"backup_note": "A backup of a running server stops it (online players are disconnected), backs up the world and starts it again, which usually takes a few minutes. A stopped server is backed up and stays stopped.",
|
||||||
|
"field_label": "Name (optional)",
|
||||||
|
"label_placeholder": "Nightly restart",
|
||||||
|
"field_enabled": "Enabled",
|
||||||
|
"enabled_hint": "Runs on schedule. Switch it off to keep the task without running it.",
|
||||||
|
"err_command_required": "Enter a command.",
|
||||||
|
"err_command_long": "The command is longer than {{max}} bytes.",
|
||||||
|
"err_time_required": "Pick a time.",
|
||||||
|
"err_days": "Pick at least one day.",
|
||||||
|
"err_timezone_required": "Enter a time zone.",
|
||||||
|
"err_timezone": "Unknown time zone. Use a name such as Asia/Shanghai.",
|
||||||
|
"err_label_long": "The name is longer than {{max}} characters.",
|
||||||
|
"next_run": "Next run {{when}}",
|
||||||
|
"next_run_off": "Off: it runs only when you press Run now",
|
||||||
|
"never_run": "Hasn't run yet",
|
||||||
|
"last_run": "Last run {{when}}",
|
||||||
|
"run_started_at": "Started {{when}}",
|
||||||
|
"result_ok": "Succeeded",
|
||||||
|
"result_skipped": "Skipped",
|
||||||
|
"result_failed": "Failed",
|
||||||
|
"result_missed": "Missed",
|
||||||
|
"reply": "Reply: {{text}}",
|
||||||
|
"run_state_claimed": "Running…",
|
||||||
|
"run_state_stopping": "Stopping the server…",
|
||||||
|
"run_state_backing_up": "Backing up…",
|
||||||
|
"run_state_starting": "Starting the server…",
|
||||||
|
"warn_badge": "Warns {{count}} min before",
|
||||||
|
"off_badge": "Off",
|
||||||
|
"tz_differs": "Times are in {{zone}}; your browser is in {{browser}}.",
|
||||||
|
"toggle_label": "Run {{name}} on schedule",
|
||||||
|
"run_now": "Run now",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"busy_hint": "This task is running. Wait for the run to finish.",
|
||||||
|
"owner_changed": "The server changed owner, so this task switched itself off. Check it, then save it or switch it on to use it again.",
|
||||||
|
"detail_not_running": "The server was not running.",
|
||||||
|
"detail_already_stopped": "The server was already stopped.",
|
||||||
|
"detail_already_running": "The server was already running.",
|
||||||
|
"detail_gone": "The server no longer exists.",
|
||||||
|
"detail_no_world": "The server has no world yet.",
|
||||||
|
"detail_missed": "felis-api was not running at the scheduled time.",
|
||||||
|
"detail_started_meanwhile": "Someone started the server before the run finished.",
|
||||||
|
"detail_interrupted": "felis-api stopped in the middle of this run.",
|
||||||
|
"detail_store_full": "The backup store is full. Ask an administrator to free space.",
|
||||||
|
"detail_console": "The server console could not be reached.",
|
||||||
|
"detail_cap": "The cluster is at its running-server cap.",
|
||||||
|
"detail_retiring": "The server is being given up or deleted.",
|
||||||
|
"run_title": "Run “{{name}}” now?",
|
||||||
|
"run_confirm": "Run now",
|
||||||
|
"run_body_command": "Sends this command to the server console now (skipped while the server is stopped):",
|
||||||
|
"run_body_restart": "Stops the server now and starts it again.",
|
||||||
|
"run_body_stop": "Stops the server now.",
|
||||||
|
"run_body_start": "Starts the server now if it is stopped.",
|
||||||
|
"run_body_backup": "Backs up the world now. A running server is stopped, backed up and started again; a stopped server stays stopped.",
|
||||||
|
"run_kick": "Online players are disconnected right away: a run started by hand skips the players' warning.",
|
||||||
|
"run_keeps_next": "The next scheduled run stays as planned.",
|
||||||
|
"delete_title": "Delete “{{name}}”?",
|
||||||
|
"delete_body": "The task is removed and won't run again.",
|
||||||
|
"delete_confirm": "Delete",
|
||||||
|
"created": "Created “{{name}}”.",
|
||||||
|
"saved": "Saved “{{name}}”.",
|
||||||
|
"deleted": "Deleted “{{name}}”.",
|
||||||
|
"run_started": "Started “{{name}}”.",
|
||||||
|
"toggled_on": "“{{name}}” is on.",
|
||||||
|
"toggled_off": "“{{name}}” is off.",
|
||||||
|
"notes_title": "How scheduled tasks run",
|
||||||
|
"note_timing": "felis-api checks every 15 seconds, so a task starts within about 15 seconds of its time. A run more than 10 minutes late (felis-api was down) is recorded as missed, and the next one runs as usual.",
|
||||||
|
"note_state": "A command, restart or stop runs only while the server is running, and a start only while it is stopped; otherwise the run is skipped.",
|
||||||
|
"note_owner": "When the server changes owner, its tasks switch off. Saving a task hands it to the current owner.",
|
||||||
|
"note_run_now": "Run now also works on a task that is off. It skips the players' warning and keeps the next scheduled run."
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@
|
|||||||
"backups_link_desc": "View and restore world backups for this server.",
|
"backups_link_desc": "View and restore world backups for this server.",
|
||||||
"files_link_title": "Server files",
|
"files_link_title": "Server files",
|
||||||
"files_link_desc": "Browse and repair files in the world volume.",
|
"files_link_desc": "Browse and repair files in the world volume.",
|
||||||
|
"schedules_link_title": "Scheduled tasks",
|
||||||
|
"schedules_link_desc": "Restart, back up or run commands at set times.",
|
||||||
"system_service": "System service",
|
"system_service": "System service",
|
||||||
"system_service_hint": "Provisioned and managed by the platform (felis systemservers); it accepts no user operations.",
|
"system_service_hint": "Provisioned and managed by the platform (felis systemservers); it accepts no user operations.",
|
||||||
"players_back_to_console": "Back to console",
|
"players_back_to_console": "Back to console",
|
||||||
|
|||||||
@@ -118,5 +118,10 @@
|
|||||||
"bad_request": "请求未被接受:{{detail}}",
|
"bad_request": "请求未被接受:{{detail}}",
|
||||||
"restore_in_progress": "这台服务器的世界正在恢复另一份备份,请等它完成后再试。",
|
"restore_in_progress": "这台服务器的世界正在恢复另一份备份,请等它完成后再试。",
|
||||||
"uploads_full": "上传存储已满,需要管理员删除已审核的提交后才能继续上传。",
|
"uploads_full": "上传存储已满,需要管理员删除已审核的提交后才能继续上传。",
|
||||||
"unsupported_media_type": "请求格式不被服务器接受,请刷新页面后重试。"
|
"unsupported_media_type": "请求格式不被服务器接受,请刷新页面后重试。",
|
||||||
|
"schedules_unavailable": "计划任务当前不可用。",
|
||||||
|
"schedule_limit": "这台服务器的计划任务数量已达上限,请先删除一个。",
|
||||||
|
"schedule_running": "这个任务正在执行,请等本次执行结束后再试。",
|
||||||
|
"schedule_stale": "保存这个任务后服务器换了所有者,请先重新保存任务再执行。",
|
||||||
|
"bad_schedule": "计划任务未保存:{{detail}}"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
{
|
||||||
|
"title": "计划任务",
|
||||||
|
"subtitle": "按设定的时间重启、关闭、启动或备份这台服务器,或向它的控制台发送命令。",
|
||||||
|
"back_to_console": "返回控制台",
|
||||||
|
"not_yours_title": "无权管理计划任务",
|
||||||
|
"not_yours_body": "只有所有者或管理员才能查看和修改该服务器的计划任务。",
|
||||||
|
"new_task": "新建任务",
|
||||||
|
"count": "{{count}} / {{limit}} 个任务",
|
||||||
|
"limit_reached": "每台服务器最多 {{limit}} 个计划任务,删除一个后才能再添加。",
|
||||||
|
"unavailable_title": "计划任务不可用",
|
||||||
|
"unavailable_body": "当前 felis-api 未配置计划任务存储,这里无法查看或保存任务。请联系管理员检查 felis-api 的配置。",
|
||||||
|
"empty_title": "还没有计划任务",
|
||||||
|
"empty_hint": "可以从常用任务开始,也可以点“新建任务”自己设置。",
|
||||||
|
"template_restart": "每天 04:00 重启",
|
||||||
|
"template_backup": "每天 05:00 备份",
|
||||||
|
"template_announce": "每 30 分钟在聊天栏发公告",
|
||||||
|
"template_announce_command": "say 欢迎来到服务器!",
|
||||||
|
"action_command": "控制台命令",
|
||||||
|
"action_command_desc": "向服务器控制台发送一条命令,效果和在控制台里输入一样。仅在服务器运行时执行。",
|
||||||
|
"action_restart": "重启",
|
||||||
|
"action_restart_desc": "关闭服务器后再启动,在线玩家会被断开。服务器已关闭时跳过。",
|
||||||
|
"action_stop": "关闭",
|
||||||
|
"action_stop_desc": "关闭服务器,在线玩家会被断开。",
|
||||||
|
"action_start": "启动",
|
||||||
|
"action_start_desc": "服务器关闭时启动它。",
|
||||||
|
"action_backup": "备份",
|
||||||
|
"action_backup_desc": "备份世界。运行中的服务器会先关闭,备份完成后再启动。",
|
||||||
|
"days_all": "每天",
|
||||||
|
"days_weekdays": "工作日",
|
||||||
|
"days_weekend": "周末",
|
||||||
|
"day_sep": "、",
|
||||||
|
"day_0": "周日",
|
||||||
|
"day_1": "周一",
|
||||||
|
"day_2": "周二",
|
||||||
|
"day_3": "周三",
|
||||||
|
"day_4": "周四",
|
||||||
|
"day_5": "周五",
|
||||||
|
"day_6": "周六",
|
||||||
|
"day_chip_0": "日",
|
||||||
|
"day_chip_1": "一",
|
||||||
|
"day_chip_2": "二",
|
||||||
|
"day_chip_3": "三",
|
||||||
|
"day_chip_4": "四",
|
||||||
|
"day_chip_5": "五",
|
||||||
|
"day_chip_6": "六",
|
||||||
|
"day_full_0": "星期日",
|
||||||
|
"day_full_1": "星期一",
|
||||||
|
"day_full_2": "星期二",
|
||||||
|
"day_full_3": "星期三",
|
||||||
|
"day_full_4": "星期四",
|
||||||
|
"day_full_5": "星期五",
|
||||||
|
"day_full_6": "星期六",
|
||||||
|
"first_day": "1",
|
||||||
|
"when_daily": "{{days}} {{time}}",
|
||||||
|
"when_interval_days": "{{every}} · {{days}}",
|
||||||
|
"every_minutes": "每 {{count}} 分钟",
|
||||||
|
"every_hours": "每 {{count}} 小时",
|
||||||
|
"opt_minutes": "{{count}} 分钟",
|
||||||
|
"opt_hours": "{{count}} 小时",
|
||||||
|
"create_title": "新建计划任务",
|
||||||
|
"edit_title": "编辑计划任务",
|
||||||
|
"dialog_desc": "选择执行什么、什么时候执行。",
|
||||||
|
"create": "创建",
|
||||||
|
"save": "保存",
|
||||||
|
"field_action": "执行什么",
|
||||||
|
"field_command": "命令",
|
||||||
|
"command_placeholder": "say 服务器将在 04:00 重启",
|
||||||
|
"command_hint": "一条控制台命令,开头的 / 可加可不加。",
|
||||||
|
"field_when": "什么时候",
|
||||||
|
"mode_daily": "每天一次",
|
||||||
|
"mode_interval": "循环执行",
|
||||||
|
"field_time": "时间",
|
||||||
|
"field_every": "间隔",
|
||||||
|
"interval_preview": "每天从 00:00 起算:{{times}}",
|
||||||
|
"interval_hourly_limit": "重启、关闭、启动和备份最多每小时执行一次。",
|
||||||
|
"field_days": "哪几天",
|
||||||
|
"field_timezone": "时区",
|
||||||
|
"timezone_hint": "按这个时区计算时间。你的浏览器时区是 {{zone}}。",
|
||||||
|
"timezone_use_browser": "改用浏览器时区",
|
||||||
|
"field_warn": "提醒玩家",
|
||||||
|
"warn_none": "不提醒",
|
||||||
|
"warn_option": "提前 {{count}} 分钟",
|
||||||
|
"warn_hint": "服务器运行时,在关闭前于游戏聊天栏发一条中英双语提醒。",
|
||||||
|
"backup_note": "备份运行中的服务器时会先关闭它(在线玩家会被断开),备份世界后再启动,通常需要几分钟。已关闭的服务器备份后保持关闭。",
|
||||||
|
"field_label": "名称(可选)",
|
||||||
|
"label_placeholder": "每晚重启",
|
||||||
|
"field_enabled": "启用",
|
||||||
|
"enabled_hint": "按计划自动执行。关闭后任务保留,但不再自动执行。",
|
||||||
|
"err_command_required": "请输入命令。",
|
||||||
|
"err_command_long": "命令超过 {{max}} 字节。",
|
||||||
|
"err_time_required": "请选择时间。",
|
||||||
|
"err_days": "至少选择一天。",
|
||||||
|
"err_timezone_required": "请输入时区。",
|
||||||
|
"err_timezone": "无法识别的时区,请填写类似 Asia/Shanghai 的名称。",
|
||||||
|
"err_label_long": "名称超过 {{max}} 个字符。",
|
||||||
|
"next_run": "下次执行:{{when}}",
|
||||||
|
"next_run_off": "已停用,只在点“立即执行”时运行",
|
||||||
|
"never_run": "尚未执行",
|
||||||
|
"last_run": "上次执行:{{when}}",
|
||||||
|
"run_started_at": "开始于:{{when}}",
|
||||||
|
"result_ok": "成功",
|
||||||
|
"result_skipped": "已跳过",
|
||||||
|
"result_failed": "失败",
|
||||||
|
"result_missed": "错过",
|
||||||
|
"reply": "回复:{{text}}",
|
||||||
|
"run_state_claimed": "执行中…",
|
||||||
|
"run_state_stopping": "正在关闭服务器…",
|
||||||
|
"run_state_backing_up": "正在备份…",
|
||||||
|
"run_state_starting": "正在启动服务器…",
|
||||||
|
"warn_badge": "提前 {{count}} 分钟提醒",
|
||||||
|
"off_badge": "已停用",
|
||||||
|
"tz_differs": "按 {{zone}} 时区计算,你的浏览器时区是 {{browser}}。",
|
||||||
|
"toggle_label": "按计划执行「{{name}}」",
|
||||||
|
"run_now": "立即执行",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"busy_hint": "任务正在执行,请等本次执行结束。",
|
||||||
|
"owner_changed": "服务器更换了所有者,这个任务已自动停用。检查无误后保存或重新启用即可恢复。",
|
||||||
|
"detail_not_running": "服务器未在运行。",
|
||||||
|
"detail_already_stopped": "服务器已经是关闭状态。",
|
||||||
|
"detail_already_running": "服务器已经在运行。",
|
||||||
|
"detail_gone": "服务器已不存在。",
|
||||||
|
"detail_no_world": "服务器还没有世界存档。",
|
||||||
|
"detail_missed": "计划时间点 felis-api 未在运行。",
|
||||||
|
"detail_started_meanwhile": "执行结束前有人启动了服务器。",
|
||||||
|
"detail_interrupted": "felis-api 在执行过程中停止了。",
|
||||||
|
"detail_store_full": "备份存储已满,请联系管理员清理空间。",
|
||||||
|
"detail_console": "无法连接服务器控制台。",
|
||||||
|
"detail_cap": "集群运行中的服务器数量已达上限。",
|
||||||
|
"detail_retiring": "服务器正在被放弃或删除。",
|
||||||
|
"run_title": "立即执行「{{name}}」?",
|
||||||
|
"run_confirm": "立即执行",
|
||||||
|
"run_body_command": "立即向服务器控制台发送下面这条命令,服务器关闭时跳过:",
|
||||||
|
"run_body_restart": "立即关闭服务器,然后重新启动。",
|
||||||
|
"run_body_stop": "立即关闭服务器。",
|
||||||
|
"run_body_start": "服务器关闭时立即启动它。",
|
||||||
|
"run_body_backup": "立即备份世界。运行中的服务器会先关闭,备份后再启动;已关闭的服务器备份后保持关闭。",
|
||||||
|
"run_kick": "在线玩家会被立即断开:手动执行不发送提醒。",
|
||||||
|
"run_keeps_next": "下次计划执行时间保持不变。",
|
||||||
|
"delete_title": "删除「{{name}}」?",
|
||||||
|
"delete_body": "任务会被删除,之后不再执行。",
|
||||||
|
"delete_confirm": "删除",
|
||||||
|
"created": "已创建「{{name}}」。",
|
||||||
|
"saved": "已保存「{{name}}」。",
|
||||||
|
"deleted": "已删除「{{name}}」。",
|
||||||
|
"run_started": "已开始执行「{{name}}」。",
|
||||||
|
"toggled_on": "已启用「{{name}}」。",
|
||||||
|
"toggled_off": "已停用「{{name}}」。",
|
||||||
|
"notes_title": "计划任务如何执行",
|
||||||
|
"note_timing": "felis-api 每 15 秒检查一次,任务会在设定时间后约 15 秒内开始。晚于计划 10 分钟以上的执行(例如 felis-api 当时未运行)会记为错过,下一次照常执行。",
|
||||||
|
"note_state": "命令、重启和关闭仅在服务器运行时执行,启动仅在服务器关闭时执行,其余情况会跳过。",
|
||||||
|
"note_owner": "服务器更换所有者后,它的任务会自动停用;重新保存即可交给当前所有者。",
|
||||||
|
"note_run_now": "“立即执行”对已停用的任务同样有效,不发送提醒,也不影响下次计划执行。"
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@
|
|||||||
"backups_link_desc": "查看并恢复该服务器的世界备份。",
|
"backups_link_desc": "查看并恢复该服务器的世界备份。",
|
||||||
"files_link_title": "服务器文件",
|
"files_link_title": "服务器文件",
|
||||||
"files_link_desc": "浏览并修复世界卷中的文件。",
|
"files_link_desc": "浏览并修复世界卷中的文件。",
|
||||||
|
"schedules_link_title": "计划任务",
|
||||||
|
"schedules_link_desc": "定时重启、备份或执行命令。",
|
||||||
"system_service": "系统服务",
|
"system_service": "系统服务",
|
||||||
"system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。",
|
"system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。",
|
||||||
"players_back_to_console": "返回控制台",
|
"players_back_to_console": "返回控制台",
|
||||||
|
|||||||
@@ -1440,6 +1440,95 @@ describe("server file manager wire shapes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scheduled task wire shapes", () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks());
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function sent(fetchSpy: typeof fetch): [string, RequestInit] {
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
return [String(url), opts as RequestInit];
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
label: "Nightly",
|
||||||
|
action: "restart" as const,
|
||||||
|
command: "",
|
||||||
|
every_minutes: 0 as const,
|
||||||
|
minute_of_day: 240,
|
||||||
|
weekdays: 127,
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
warn_minutes: 5 as const,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
const saved = { ...input, id: 7, server: "survival", next_run_at: "2026-09-28T04:00:00+08:00", run_state: "" };
|
||||||
|
|
||||||
|
it("listSchedules GETs /servers/{name}/schedules and keeps the limit", async () => {
|
||||||
|
const fetchSpy = fakeFetch({ server: "survival", schedules: [saved], limit: 20 });
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
expect(await api.listSchedules("survival")).toEqual({ schedules: [saved], limit: 20 });
|
||||||
|
const [url, opts] = sent(fetchSpy);
|
||||||
|
expect(url).toBe("/servers/survival/schedules");
|
||||||
|
expect(opts.method).toBe("GET");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listSchedules reads a missing list as none", async () => {
|
||||||
|
vi.stubGlobal("fetch", fakeFetch({ server: "survival", limit: 20 }));
|
||||||
|
expect(await api.listSchedules("survival")).toEqual({ schedules: [], limit: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createSchedule POSTs the input as it is", async () => {
|
||||||
|
const fetchSpy = fakeFetch(saved, { status: 201 });
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
expect(await api.createSchedule("survival", input)).toEqual(saved);
|
||||||
|
const [url, opts] = sent(fetchSpy);
|
||||||
|
expect(url).toBe("/servers/survival/schedules");
|
||||||
|
expect(opts.method).toBe("POST");
|
||||||
|
expect(JSON.parse(opts.body as string)).toEqual(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateSchedule PUTs the whole input to the schedule's id", async () => {
|
||||||
|
const fetchSpy = fakeFetch(saved);
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
expect(await api.updateSchedule("survival", 7, { ...input, enabled: false })).toEqual(saved);
|
||||||
|
const [url, opts] = sent(fetchSpy);
|
||||||
|
expect(url).toBe("/servers/survival/schedules/7");
|
||||||
|
expect(opts.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(opts.body as string)).toEqual({ ...input, enabled: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteSchedule DELETEs the schedule with no body and takes the 204", async () => {
|
||||||
|
const fetchSpy = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
statusText: "No Content",
|
||||||
|
text: async () => "",
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
expect(await api.deleteSchedule("survival", 7)).toBeNull();
|
||||||
|
const [url, opts] = sent(fetchSpy);
|
||||||
|
expect(url).toBe("/servers/survival/schedules/7");
|
||||||
|
expect(opts.method).toBe("DELETE");
|
||||||
|
expect(opts.body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runSchedule POSTs to .../run with no body and parses the 202", async () => {
|
||||||
|
const fetchSpy = fakeFetch({ ...saved, run_state: "stopping" }, { status: 202 });
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
expect((await api.runSchedule("survival", 7)).run_state).toBe("stopping");
|
||||||
|
const [url, opts] = sent(fetchSpy);
|
||||||
|
expect(url).toBe("/servers/survival/schedules/7/run");
|
||||||
|
expect(opts.method).toBe("POST");
|
||||||
|
expect(opts.body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a server name that is not one path segment before sending anything", async () => {
|
||||||
|
const fetchSpy = fakeFetch({});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
await expect(api.runSchedule("..", 7)).rejects.toMatchObject({ code: "bad_path_param" });
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The API's generic codes carry an English developer message ("user not found",
|
// The API's generic codes carry an English developer message ("user not found",
|
||||||
// "invalid request"); the panel words them itself so a Chinese UI never shows it.
|
// "invalid request"); the panel words them itself so a Chinese UI never shows it.
|
||||||
describe("copy for the generic server codes", () => {
|
describe("copy for the generic server codes", () => {
|
||||||
@@ -1470,6 +1559,25 @@ describe("copy for the generic server codes", () => {
|
|||||||
expect(humanizeError({ status: 411, code: "length_required", message: "raw" })).toMatch(/did not say how large it is/);
|
expect(humanizeError({ status: 411, code: "length_required", message: "raw" })).toMatch(/did not say how large it is/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("words the scheduled tasks' refusals itself, and keeps the reason a schedule was refused", () => {
|
||||||
|
expect(humanizeError({ status: 503, code: "schedules_unavailable", message: "raw" })).toBe(
|
||||||
|
"Scheduled tasks aren't available right now.",
|
||||||
|
);
|
||||||
|
expect(humanizeError({ status: 409, code: "schedule_limit", message: "raw" })).toBe(
|
||||||
|
"This server already has as many scheduled tasks as it can hold. Delete one first.",
|
||||||
|
);
|
||||||
|
expect(humanizeError({ status: 409, code: "schedule_running", message: "raw" })).toBe(
|
||||||
|
"This task is running right now. Try again once the run finishes.",
|
||||||
|
);
|
||||||
|
expect(humanizeError({ status: 409, code: "schedule_stale", message: "raw" })).toBe(
|
||||||
|
"The server has a new owner since this task was saved. Save the task again before running it.",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
humanizeError({ status: 400, code: "bad_schedule", message: "a restart can repeat at most every 60 minutes" }),
|
||||||
|
).toBe("The task was not saved: a restart can repeat at most every 60 minutes");
|
||||||
|
expect(humanizeError({ status: 400, code: "bad_schedule", message: "" })).toBe("Something went wrong.");
|
||||||
|
});
|
||||||
|
|
||||||
it("reads a full upload store as full, not as an outage", () => {
|
it("reads a full upload store as full, not as an outage", () => {
|
||||||
expect(humanizeError({ status: 507, code: "uploads_full", message: "" })).toMatch(/upload store is full/);
|
expect(humanizeError({ status: 507, code: "uploads_full", message: "" })).toMatch(/upload store is full/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type {
|
|||||||
QuotaInput,
|
QuotaInput,
|
||||||
QuotaView,
|
QuotaView,
|
||||||
RetireState,
|
RetireState,
|
||||||
|
Schedule,
|
||||||
|
ScheduleInput,
|
||||||
ServerFileEntry,
|
ServerFileEntry,
|
||||||
ServerJob,
|
ServerJob,
|
||||||
MyServerView,
|
MyServerView,
|
||||||
@@ -681,6 +683,41 @@ export const api = rejectingSync({
|
|||||||
urlPath`/servers/${name}/jobs`,
|
urlPath`/servers/${name}/jobs`,
|
||||||
).then((r) => r.jobs ?? []),
|
).then((r) => r.jobs ?? []),
|
||||||
|
|
||||||
|
// Scheduled tasks of one server (GET/POST /servers/{name}/schedules, PUT/DELETE
|
||||||
|
// .../{id}, POST .../{id}/run). Owner-or-admin gated server-side; 503
|
||||||
|
// schedules_unavailable while the schedule store is not wired. A server holds
|
||||||
|
// at most `limit` of them (409 schedule_limit past it).
|
||||||
|
listSchedules: (name: string) =>
|
||||||
|
request<{ server: string; schedules: Schedule[]; limit: number }>(
|
||||||
|
"GET",
|
||||||
|
urlPath`/servers/${name}/schedules`,
|
||||||
|
).then((r) => ({ schedules: r.schedules ?? [], limit: r.limit })),
|
||||||
|
|
||||||
|
// createSchedule saves a new schedule; it belongs to the server's current
|
||||||
|
// owner. Validation failures are 400 bad_request / bad_schedule with the reason
|
||||||
|
// in the message.
|
||||||
|
createSchedule: (name: string, input: ScheduleInput) =>
|
||||||
|
request<Schedule>("POST", urlPath`/servers/${name}/schedules`, input),
|
||||||
|
|
||||||
|
// updateSchedule replaces a schedule's settings (the whole input, not a patch)
|
||||||
|
// and passes it to the server's current owner, which is how a schedule the
|
||||||
|
// runner disabled after an owner change comes back. 409 schedule_running while
|
||||||
|
// it runs.
|
||||||
|
updateSchedule: (name: string, id: number, input: ScheduleInput) =>
|
||||||
|
request<Schedule>("PUT", urlPath`/servers/${name}/schedules/${String(id)}`, input),
|
||||||
|
|
||||||
|
// deleteSchedule answers 204; 409 schedule_running while it runs.
|
||||||
|
deleteSchedule: (name: string, id: number) =>
|
||||||
|
request<null>("DELETE", urlPath`/servers/${name}/schedules/${String(id)}`),
|
||||||
|
|
||||||
|
// runSchedule runs a schedule now, without the players' warning; its next
|
||||||
|
// scheduled run stays where it is. The reply is 202 with the schedule after the
|
||||||
|
// run's first step: a restart or backup goes on in the background and shows in
|
||||||
|
// run_state. 409 schedule_running mid-run, 409 schedule_stale when the server
|
||||||
|
// changed owner since the schedule was saved.
|
||||||
|
runSchedule: (name: string, id: number) =>
|
||||||
|
request<Schedule>("POST", urlPath`/servers/${name}/schedules/${String(id)}/run`),
|
||||||
|
|
||||||
// Server file manager (spec §7). Every route is owner-or-admin gated and
|
// Server file manager (spec §7). Every route is owner-or-admin gated and
|
||||||
// refuses with 409 not_stopped unless the server is fully stopped (the world
|
// refuses with 409 not_stopped unless the server is fully stopped (the world
|
||||||
// volume is RWO), so callers gate on phase === "Stopped". The path travels as a
|
// volume is RWO), so callers gate on phase === "Stopped". The path travels as a
|
||||||
@@ -1344,10 +1381,21 @@ export function humanizeError(e: unknown): string {
|
|||||||
return t("uploads_full");
|
return t("uploads_full");
|
||||||
case "unsupported_media_type":
|
case "unsupported_media_type":
|
||||||
return t("unsupported_media_type");
|
return t("unsupported_media_type");
|
||||||
|
// Scheduled tasks (internal/api/handlers_schedules.go).
|
||||||
|
case "schedules_unavailable":
|
||||||
|
return t("schedules_unavailable");
|
||||||
|
case "schedule_limit":
|
||||||
|
return t("schedule_limit");
|
||||||
|
case "schedule_running":
|
||||||
|
return t("schedule_running");
|
||||||
|
case "schedule_stale":
|
||||||
|
return t("schedule_stale");
|
||||||
// The detail says which field was wrong; the server writes it in English,
|
// The detail says which field was wrong; the server writes it in English,
|
||||||
// so it rides inside a localized sentence.
|
// so it rides inside a localized sentence.
|
||||||
case "bad_request":
|
case "bad_request":
|
||||||
return err.message ? t("bad_request", { detail: err.message }) : t("generic");
|
return err.message ? t("bad_request", { detail: err.message }) : t("generic");
|
||||||
|
case "bad_schedule":
|
||||||
|
return err.message ? t("bad_schedule", { detail: err.message }) : t("generic");
|
||||||
default:
|
default:
|
||||||
if (err.status === 401) return t("session_expired");
|
if (err.status === 401) return t("session_expired");
|
||||||
if (err.status === 403) return t("forbidden");
|
if (err.status === 403) return t("forbidden");
|
||||||
|
|||||||
@@ -1382,6 +1382,71 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/servers/{name}/schedules": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List a server's scheduled tasks (owner-or-admin). */
|
||||||
|
get: operations["listServerSchedules"];
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Add a scheduled task to a server (owner-or-admin).
|
||||||
|
* @description The task belongs to the server's current owner: once the server has another owner felis-api disables it instead of running it, until somebody saves it again. felis-api checks the tasks every 15 seconds; a run it was down for is started late, up to 10 minutes, and dropped as missed after that. A command runs only on a running server, a restart only restarts a running one, and a start goes through the running-server cap and a pending retirement like a wake. Audited as schedule.create; each run as schedule.run by scheduler.
|
||||||
|
*/
|
||||||
|
post: operations["createServerSchedule"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/servers/{name}/schedules/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Change a scheduled task (owner-or-admin).
|
||||||
|
* @description Replaces the task's settings and recomputes its next run. The task passes to the server's current owner. Refused while a run is in progress. Audited as schedule.update.
|
||||||
|
*/
|
||||||
|
put: operations["updateServerSchedule"];
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Remove a scheduled task (owner-or-admin).
|
||||||
|
* @description Refused while a run is in progress. Audited as schedule.delete.
|
||||||
|
*/
|
||||||
|
delete: operations["deleteServerSchedule"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/servers/{name}/schedules/{id}/run": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Run a scheduled task now (owner-or-admin).
|
||||||
|
* @description Starts a run at once, without the players' warning, whether the task is enabled or not; its next scheduled run stays where it was. The answer is the task after the run's first step: a command, stop or start has finished, and a restart or backup goes on in the background (run_state). Audited as schedule.run_now, and the run itself as schedule.run.
|
||||||
|
*/
|
||||||
|
post: operations["runServerSchedule"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/users": {
|
"/api/v1/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2639,6 +2704,76 @@ export interface components {
|
|||||||
/** @description Owned rows only. Present while the server is given up or being deleted. */
|
/** @description Owned rows only. Present while the server is given up or being deleted. */
|
||||||
retiring?: components["schemas"]["RetireState"];
|
retiring?: components["schemas"]["RetireState"];
|
||||||
};
|
};
|
||||||
|
/** @description One scheduled task of a server (internal/api/schedules.go Schedule). It runs at minute_of_day on the weekdays, or with every_minutes set at every multiple of it since midnight on those days, in timezone. */
|
||||||
|
Schedule: {
|
||||||
|
/** Format: int64 */
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
/** @description Free text naming the task; may be empty. */
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* @description backup of a running server stops it, takes a backup (pruned with the daily restore points, [archive] scheduled_keep) and starts it again; of a stopped server it leaves the server stopped.
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
action: "command" | "restart" | "stop" | "start" | "backup";
|
||||||
|
/** @description The console command of a command task */
|
||||||
|
command: string;
|
||||||
|
/**
|
||||||
|
* @description 0 runs once a day at minute_of_day. A restart, stop, start or backup repeats at most every 60 minutes.
|
||||||
|
* @enum {integer}
|
||||||
|
*/
|
||||||
|
every_minutes: 0 | 15 | 30 | 60 | 120 | 180 | 240 | 360 | 480 | 720;
|
||||||
|
/** @description Minutes after local midnight; 0 when every_minutes is set. */
|
||||||
|
minute_of_day: number;
|
||||||
|
/** @description Bitmask of the days it runs on: bit 0 Sunday to bit 6 Saturday. */
|
||||||
|
weekdays: number;
|
||||||
|
/** @description IANA zone the times are in */
|
||||||
|
timezone: string;
|
||||||
|
/**
|
||||||
|
* @description How long before a restart, stop or backup the players on the server are told (say); 0 for none, and always 0 for a command or start.
|
||||||
|
* @enum {integer}
|
||||||
|
*/
|
||||||
|
warn_minutes: 0 | 1 | 5 | 10 | 15 | 30;
|
||||||
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* Format: date-time
|
||||||
|
* @description Null while disabled.
|
||||||
|
*/
|
||||||
|
next_run_at: string | null;
|
||||||
|
/**
|
||||||
|
* @description What a run in progress is doing; empty when none is.
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
run_state: "" | "claimed" | "stopping" | "backing_up" | "starting";
|
||||||
|
/** Format: date-time */
|
||||||
|
last_run_at: string | null;
|
||||||
|
/**
|
||||||
|
* @description How the last run ended; empty before the first and during a run. missed is a run felis-api was down for, dropped once it was 10 minutes late.
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
last_result: "" | "ok" | "skipped" | "failed" | "missed";
|
||||||
|
/** @description What happened */
|
||||||
|
last_detail: string;
|
||||||
|
created_by: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
ScheduleInput: {
|
||||||
|
label?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
action: "command" | "restart" | "stop" | "start" | "backup";
|
||||||
|
/** @description Required for a command task and refused for the others. One line; a leading slash is dropped. */
|
||||||
|
command?: string;
|
||||||
|
/** @enum {integer} */
|
||||||
|
every_minutes?: 0 | 15 | 30 | 60 | 120 | 180 | 240 | 360 | 480 | 720;
|
||||||
|
minute_of_day?: number;
|
||||||
|
weekdays: number;
|
||||||
|
timezone: string;
|
||||||
|
/** @enum {integer} */
|
||||||
|
warn_minutes?: 0 | 1 | 5 | 10 | 15 | 30;
|
||||||
|
/** @description Default true. */
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
/** @description One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). */
|
/** @description One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). */
|
||||||
BackupView: {
|
BackupView: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6640,6 +6775,206 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listServerSchedules: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description The server's tasks, oldest first, and how many it may have. */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
server: string;
|
||||||
|
schedules: components["schemas"]["Schedule"][];
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
404: components["responses"]["NotFound"];
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createServerSchedule: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ScheduleInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created. */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Schedule"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description A malformed body or name, or settings out of range (bad_schedule, bad_request for the command). */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
404: components["responses"]["NotFound"];
|
||||||
|
/** @description The server already has 20 tasks (schedule_limit). */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateServerSchedule: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ScheduleInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Saved. */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Schedule"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description A malformed body, name or id, or settings out of range (bad_schedule). */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
404: components["responses"]["NotFound"];
|
||||||
|
/** @description A run is in progress (schedule_running). */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteServerSchedule: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Removed. */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
404: components["responses"]["NotFound"];
|
||||||
|
/** @description A run is in progress (schedule_running). */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
runServerSchedule: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Started. */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Schedule"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
404: components["responses"]["NotFound"];
|
||||||
|
/** @description A run is already in progress (schedule_running), or the server has another owner since the task was saved (schedule_stale). */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listUsers: {
|
listUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export type WireParity = [
|
|||||||
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
|
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
|
||||||
Holds<Parity<T.AllowlistEntry, S["AllowlistEntry"]>>,
|
Holds<Parity<T.AllowlistEntry, S["AllowlistEntry"]>>,
|
||||||
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
|
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
|
||||||
|
Holds<Parity<T.Schedule, S["Schedule"]>>,
|
||||||
|
Holds<Parity<T.ScheduleInput, S["ScheduleInput"]>>,
|
||||||
Holds<Parity<T.BackupView, S["BackupView"]>>,
|
Holds<Parity<T.BackupView, S["BackupView"]>>,
|
||||||
Holds<Parity<T.Build, S["Build"]>>,
|
Holds<Parity<T.Build, S["Build"]>>,
|
||||||
Holds<Parity<T.BuildScan, S["BuildScan"]>>,
|
Holds<Parity<T.BuildScan, S["BuildScan"]>>,
|
||||||
|
|||||||
@@ -247,6 +247,62 @@ export interface ServerJob {
|
|||||||
scheduled?: boolean;
|
scheduled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Scheduled tasks (internal/api/schedules.go Schedule, scheduleInput) ----
|
||||||
|
|
||||||
|
export type ScheduleAction = "command" | "restart" | "stop" | "start" | "backup";
|
||||||
|
/** 0 runs once a day at minute_of_day; the rest divide a day, so the runs sit at
|
||||||
|
* the same clock times every day. A restart, stop, start or backup repeats at
|
||||||
|
* most every 60 minutes. */
|
||||||
|
export type ScheduleEveryMinutes = 0 | 15 | 30 | 60 | 120 | 180 | 240 | 360 | 480 | 720;
|
||||||
|
/** How long before a restart, stop or backup the players are told in game. */
|
||||||
|
export type ScheduleWarnMinutes = 0 | 1 | 5 | 10 | 15 | 30;
|
||||||
|
/** The step of a run in progress; empty when none is. */
|
||||||
|
export type ScheduleRunState = "" | "claimed" | "stopping" | "backing_up" | "starting";
|
||||||
|
/** How the last run ended; empty before the first and during a run. */
|
||||||
|
export type ScheduleResult = "" | "ok" | "skipped" | "failed" | "missed";
|
||||||
|
|
||||||
|
/** Schedule is one scheduled task of a server (GET /servers/{name}/schedules).
|
||||||
|
* It runs at minute_of_day on the weekdays (bit 0 Sunday … bit 6 Saturday), or
|
||||||
|
* with every_minutes set at every multiple of it since midnight on those days,
|
||||||
|
* in timezone. last_detail is the backend's English. */
|
||||||
|
export interface Schedule {
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
label: string;
|
||||||
|
action: ScheduleAction;
|
||||||
|
/** The console command of a command task, without a slash; empty otherwise. */
|
||||||
|
command: string;
|
||||||
|
every_minutes: ScheduleEveryMinutes;
|
||||||
|
minute_of_day: number;
|
||||||
|
weekdays: number;
|
||||||
|
timezone: string;
|
||||||
|
warn_minutes: ScheduleWarnMinutes;
|
||||||
|
enabled: boolean;
|
||||||
|
/** Null while disabled. */
|
||||||
|
next_run_at: string | null;
|
||||||
|
run_state: ScheduleRunState;
|
||||||
|
last_run_at: string | null;
|
||||||
|
last_result: ScheduleResult;
|
||||||
|
last_detail: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ScheduleInput is the body of a create (POST) or a save (PUT). The server trims
|
||||||
|
* the label, drops a command's leading slash, and refuses a command on any other
|
||||||
|
* action and a warning on a command or start. enabled defaults to true. */
|
||||||
|
export interface ScheduleInput {
|
||||||
|
label?: string;
|
||||||
|
action: ScheduleAction;
|
||||||
|
command?: string;
|
||||||
|
every_minutes?: ScheduleEveryMinutes;
|
||||||
|
minute_of_day?: number;
|
||||||
|
weekdays: number;
|
||||||
|
timezone: string;
|
||||||
|
warn_minutes?: ScheduleWarnMinutes;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
|
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
|
||||||
export interface WhitelistImage {
|
export interface WhitelistImage {
|
||||||
image_ref: string;
|
image_ref: string;
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ describe("ServerConsole edit dialog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ServerConsole doorways", () => {
|
||||||
|
it("links to the server's scheduled tasks while it is stopped", async () => {
|
||||||
|
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
|
||||||
|
renderConsole();
|
||||||
|
|
||||||
|
const link = await screen.findByRole("link", { name: /^Scheduled tasks/ });
|
||||||
|
expect(link.getAttribute("href")).toBe("/servers/survival/schedules");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ServerConsole following the server", () => {
|
describe("ServerConsole following the server", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useCallback, useId, useLayoutEffect, type KeyboardEvent } from "react";
|
import { useState, useRef, useCallback, useId, useLayoutEffect, type KeyboardEvent } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, ChevronRight, type LucideIcon } from "lucide-react";
|
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, CalendarClock, ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { BackLink } from "@/components/BackLink";
|
import { BackLink } from "@/components/BackLink";
|
||||||
@@ -411,6 +411,21 @@ export function ServerConsole() {
|
|||||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Scheduled tasks — its own subpage. A schedule runs whatever phase the
|
||||||
|
server is in (a start wakes it, a backup stops it first), so the
|
||||||
|
doorway shows at every phase and the page owns its gating. */}
|
||||||
|
<Link
|
||||||
|
to={`/servers/${name}/schedules`}
|
||||||
|
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<CalendarClock className="h-5 w-5 shrink-0 text-primary" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{t("schedules_link_title")}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("schedules_link_desc")}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Giving the server up (owner) or deleting it (admin) closes the
|
{/* Giving the server up (owner) or deleting it (admin) closes the
|
||||||
sidebar. A system server is never retired, and one already on its
|
sidebar. A system server is never retired, and one already on its
|
||||||
way out shows the notice above with the way back instead. */}
|
way out shows the notice above with the way back instead. */}
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { ServerSchedules } from "./ServerSchedules";
|
||||||
|
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
|
||||||
|
import type { Schedule } from "@/lib/types";
|
||||||
|
|
||||||
|
const calls = vi.hoisted(() => ({
|
||||||
|
status: vi.fn(),
|
||||||
|
myServers: vi.fn(),
|
||||||
|
listSchedules: vi.fn(),
|
||||||
|
createSchedule: vi.fn(),
|
||||||
|
updateSchedule: vi.fn(),
|
||||||
|
deleteSchedule: vi.fn(),
|
||||||
|
runSchedule: vi.fn(),
|
||||||
|
}));
|
||||||
|
const tier = vi.hoisted(() => ({
|
||||||
|
loading: false,
|
||||||
|
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
|
||||||
|
isAdmin: true,
|
||||||
|
isOwner: false,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
|
||||||
|
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
|
||||||
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
|
return { ...actual, api: { ...actual.api, ...calls } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Radix Select opens with pointer capture and scrolls the picked item into view,
|
||||||
|
// neither of which jsdom implements.
|
||||||
|
beforeAll(() => {
|
||||||
|
Element.prototype.hasPointerCapture ??= () => false;
|
||||||
|
Element.prototype.releasePointerCapture ??= () => {};
|
||||||
|
Element.prototype.scrollIntoView ??= () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const BROWSER = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const OTHER = BROWSER === "Pacific/Chatham" ? "Pacific/Kiritimati" : "Pacific/Chatham";
|
||||||
|
const KICK = "Online players are disconnected right away: a run started by hand skips the players' warning.";
|
||||||
|
|
||||||
|
function hoursFromNow(h: number): string {
|
||||||
|
return new Date(Date.now() + h * 3600_000 + 60_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(over: Partial<Schedule> = {}): Schedule {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
server: "survival",
|
||||||
|
label: "",
|
||||||
|
action: "restart",
|
||||||
|
command: "",
|
||||||
|
every_minutes: 0,
|
||||||
|
minute_of_day: 240,
|
||||||
|
weekdays: 127,
|
||||||
|
timezone: BROWSER,
|
||||||
|
warn_minutes: 5,
|
||||||
|
enabled: true,
|
||||||
|
next_run_at: hoursFromNow(3),
|
||||||
|
run_state: "",
|
||||||
|
last_run_at: null,
|
||||||
|
last_result: "",
|
||||||
|
last_detail: "",
|
||||||
|
created_by: "user:u1",
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listing(schedules: Schedule[], limit = 20) {
|
||||||
|
calls.listSchedules.mockResolvedValue({ schedules, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(calls)) fn.mockReset();
|
||||||
|
tier.isAdmin = true;
|
||||||
|
calls.status.mockResolvedValue({ name: "survival", displayName: "Survival", phase: "Running" });
|
||||||
|
calls.myServers.mockResolvedValue([]);
|
||||||
|
listing([]);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
return i18next.changeLanguage("en-US");
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={["/servers/survival/schedules"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/servers/:name/schedules" element={<ServerSchedules />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rowOf(title: string): Promise<HTMLElement> {
|
||||||
|
return (await screen.findByText(title, { selector: "p" })).closest("li") as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreate() {
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "New task" }));
|
||||||
|
return screen.findByRole("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pick(dialog: HTMLElement, box: string, option: string) {
|
||||||
|
await userEvent.click(within(dialog).getByRole("combobox", { name: box }));
|
||||||
|
await userEvent.click(await screen.findByRole("option", { name: option }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ServerSchedules", () => {
|
||||||
|
it("shows a non-owner NotYours and never reads the list", async () => {
|
||||||
|
tier.isAdmin = false;
|
||||||
|
calls.myServers.mockResolvedValue([{ name: "survival", owned: false }]);
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByText("No permission to manage scheduled tasks")).toBeTruthy();
|
||||||
|
expect(calls.listSchedules).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole("button", { name: "New task" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an owner who is not an admin the list", async () => {
|
||||||
|
tier.isAdmin = false;
|
||||||
|
calls.myServers.mockResolvedValue([{ name: "survival", owned: true }]);
|
||||||
|
listing([schedule({ label: "Nightly" })]);
|
||||||
|
renderPage();
|
||||||
|
expect(await rowOf("Nightly")).toBeTruthy();
|
||||||
|
expect(calls.listSchedules).toHaveBeenCalledWith("survival");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists each task with its summary, next run and last result", async () => {
|
||||||
|
listing([
|
||||||
|
schedule({ id: 1, label: "Nightly", last_result: "ok", last_run_at: hoursFromNow(-22) }),
|
||||||
|
schedule({
|
||||||
|
id: 2,
|
||||||
|
action: "command",
|
||||||
|
command: "say hi",
|
||||||
|
every_minutes: 30,
|
||||||
|
minute_of_day: 0,
|
||||||
|
weekdays: 62,
|
||||||
|
timezone: OTHER,
|
||||||
|
warn_minutes: 0,
|
||||||
|
last_result: "failed",
|
||||||
|
last_run_at: hoursFromNow(-2),
|
||||||
|
last_detail: "the command failed: rcon: connection refused",
|
||||||
|
}),
|
||||||
|
schedule({ id: 3, action: "backup", weekdays: 42, minute_of_day: 330, warn_minutes: 0, enabled: false, next_run_at: null }),
|
||||||
|
]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const nightly = await rowOf("Nightly");
|
||||||
|
expect(within(nightly).getByText("Every day at 04:00")).toBeTruthy();
|
||||||
|
expect(within(nightly).getByText("Next run in 3 hours")).toBeTruthy();
|
||||||
|
expect(within(nightly).getByText("Succeeded")).toBeTruthy();
|
||||||
|
expect(within(nightly).getByText("Last run 22 hours ago")).toBeTruthy();
|
||||||
|
expect(within(nightly).getByText("Warns 5 min before")).toBeTruthy();
|
||||||
|
expect(within(nightly).getByText("Restart")).toBeTruthy();
|
||||||
|
expect(within(nightly).queryByText(BROWSER)).toBeNull();
|
||||||
|
|
||||||
|
const command = await rowOf("Console command");
|
||||||
|
expect(within(command).getByText("Every 30 minutes · Weekdays")).toBeTruthy();
|
||||||
|
expect(within(command).getByText("say hi", { selector: "code" })).toBeTruthy();
|
||||||
|
expect(within(command).getByText(OTHER)).toBeTruthy();
|
||||||
|
expect(within(command).getByText("Failed")).toBeTruthy();
|
||||||
|
expect(within(command).getByText("the command failed: rcon: connection refused")).toBeTruthy();
|
||||||
|
expect(within(command).queryByText(/^Warns/)).toBeNull();
|
||||||
|
|
||||||
|
const backup = await rowOf("Backup");
|
||||||
|
expect(within(backup).getByText("Mon, Wed, Fri at 05:30")).toBeTruthy();
|
||||||
|
expect(within(backup).getByText("Off: it runs only when you press Run now")).toBeTruthy();
|
||||||
|
expect(within(backup).getByText("Off")).toBeTruthy();
|
||||||
|
expect(within(backup).getByText("Hasn't run yet")).toBeTruthy();
|
||||||
|
|
||||||
|
expect(screen.getByText("3 of 20 tasks")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the week on Monday in Chinese", async () => {
|
||||||
|
await i18next.changeLanguage("zh-CN");
|
||||||
|
listing([schedule({ id: 1, label: "夜间重启", weekdays: 3 })]);
|
||||||
|
renderPage();
|
||||||
|
expect(within(await rowOf("夜间重启")).getByText("周一、周日 04:00")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says in the panel's words why a run was skipped, and shows a command's reply", async () => {
|
||||||
|
listing([
|
||||||
|
schedule({
|
||||||
|
id: 1,
|
||||||
|
label: "Nightly",
|
||||||
|
enabled: false,
|
||||||
|
next_run_at: null,
|
||||||
|
last_result: "skipped",
|
||||||
|
last_run_at: hoursFromNow(-1),
|
||||||
|
last_detail: "the server has a new owner since this schedule was saved; save it again to use it",
|
||||||
|
}),
|
||||||
|
schedule({
|
||||||
|
id: 2,
|
||||||
|
label: "Who",
|
||||||
|
action: "command",
|
||||||
|
command: "list",
|
||||||
|
warn_minutes: 0,
|
||||||
|
last_result: "ok",
|
||||||
|
last_run_at: hoursFromNow(-1),
|
||||||
|
last_detail: "There are 0 of a max of 20 players online",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
renderPage();
|
||||||
|
const nightly = await rowOf("Nightly");
|
||||||
|
expect(within(nightly).getByText("Skipped")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(nightly).getByText(
|
||||||
|
"The server changed owner, so this task switched itself off. Check it, then save it or switch it on to use it again.",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(within(await rowOf("Who")).getByText("Reply: There are 0 of a max of 20 players online")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a daily restart that warns the players", async () => {
|
||||||
|
calls.createSchedule.mockResolvedValue(schedule({ id: 9, label: "Nightly" }));
|
||||||
|
renderPage();
|
||||||
|
const dialog = await openCreate();
|
||||||
|
const restart = within(dialog).getByRole("radio", { name: "Restart" }) as HTMLInputElement;
|
||||||
|
expect(restart.checked).toBe(true);
|
||||||
|
// A command typed before switching back to a restart is not sent with it.
|
||||||
|
await userEvent.click(within(dialog).getByRole("radio", { name: "Console command" }));
|
||||||
|
await userEvent.type(within(dialog).getByLabelText("Command"), "say bye");
|
||||||
|
await userEvent.click(restart);
|
||||||
|
fireEvent.change(within(dialog).getByLabelText("Time"), { target: { value: "03:30" } });
|
||||||
|
await pick(dialog, "Warn players", "10 minutes before");
|
||||||
|
await userEvent.type(within(dialog).getByLabelText("Name (optional)"), "Nightly");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(calls.createSchedule).toHaveBeenCalledWith("survival", {
|
||||||
|
label: "Nightly",
|
||||||
|
action: "restart",
|
||||||
|
command: "",
|
||||||
|
every_minutes: 0,
|
||||||
|
minute_of_day: 210,
|
||||||
|
weekdays: 127,
|
||||||
|
timezone: BROWSER,
|
||||||
|
warn_minutes: 10,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("Created “Nightly”.")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(calls.listSchedules).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a repeating console command as typed, on weekdays", async () => {
|
||||||
|
calls.createSchedule.mockResolvedValue(schedule({ id: 9, action: "command" }));
|
||||||
|
renderPage();
|
||||||
|
const dialog = await openCreate();
|
||||||
|
await userEvent.click(within(dialog).getByRole("radio", { name: "Console command" }));
|
||||||
|
await userEvent.type(within(dialog).getByLabelText("Command"), "/say hi");
|
||||||
|
await userEvent.click(within(dialog).getByRole("radio", { name: "Repeat" }));
|
||||||
|
await pick(dialog, "Repeat every", "30 minutes");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Weekdays" }));
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(calls.createSchedule).toHaveBeenCalledWith("survival", {
|
||||||
|
label: "",
|
||||||
|
action: "command",
|
||||||
|
command: "/say hi",
|
||||||
|
every_minutes: 30,
|
||||||
|
minute_of_day: 0,
|
||||||
|
weekdays: 62,
|
||||||
|
timezone: BROWSER,
|
||||||
|
warn_minutes: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the warning for a command and keeps a server action hourly at most", async () => {
|
||||||
|
renderPage();
|
||||||
|
const dialog = await openCreate();
|
||||||
|
await pick(dialog, "Warn players", "10 minutes before");
|
||||||
|
expect(within(dialog).getByRole("combobox", { name: "Warn players" }).textContent).toBe("10 minutes before");
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole("radio", { name: "Console command" }));
|
||||||
|
expect(within(dialog).queryByRole("combobox", { name: "Warn players" })).toBeNull();
|
||||||
|
await userEvent.click(within(dialog).getByRole("radio", { name: "Repeat" }));
|
||||||
|
await pick(dialog, "Repeat every", "15 minutes");
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole("radio", { name: "Stop" }));
|
||||||
|
expect(within(dialog).getByRole("combobox", { name: "Warn players" }).textContent).toBe("No warning");
|
||||||
|
expect(within(dialog).getByRole("combobox", { name: "Repeat every" }).textContent).toBe("1 hour");
|
||||||
|
await userEvent.click(within(dialog).getByRole("combobox", { name: "Repeat every" }));
|
||||||
|
expect(await screen.findByRole("option", { name: "1 hour" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("option", { name: "30 minutes" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not save a task on no day", async () => {
|
||||||
|
renderPage();
|
||||||
|
const dialog = await openCreate();
|
||||||
|
for (const day of ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]) {
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: day }));
|
||||||
|
}
|
||||||
|
expect(within(dialog).getByText("Pick at least one day.")).toBeTruthy();
|
||||||
|
const create = within(dialog).getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
||||||
|
expect(create.disabled).toBe(true);
|
||||||
|
await userEvent.click(create);
|
||||||
|
expect(calls.createSchedule).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Wednesday" }));
|
||||||
|
expect(within(dialog).queryByText("Pick at least one day.")).toBeNull();
|
||||||
|
expect(create.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the command, name and time zone the way felis-api does", async () => {
|
||||||
|
renderPage();
|
||||||
|
const dialog = await openCreate();
|
||||||
|
const create = within(dialog).getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
||||||
|
await userEvent.click(within(dialog).getByRole("radio", { name: "Console command" }));
|
||||||
|
const command = within(dialog).getByLabelText("Command");
|
||||||
|
expect(within(dialog).getByText("Enter a command.")).toBeTruthy();
|
||||||
|
// A lone slash is dropped, leaving nothing to run.
|
||||||
|
fireEvent.change(command, { target: { value: " / " } });
|
||||||
|
expect(within(dialog).getByText("Enter a command.")).toBeTruthy();
|
||||||
|
expect(create.disabled).toBe(true);
|
||||||
|
// The limit counts the command after its slash goes.
|
||||||
|
fireEvent.change(command, { target: { value: "/" + "x".repeat(1024) } });
|
||||||
|
expect(create.disabled).toBe(false);
|
||||||
|
fireEvent.change(command, { target: { value: "x".repeat(1025) } });
|
||||||
|
expect(within(dialog).getByText("The command is longer than 1024 bytes.")).toBeTruthy();
|
||||||
|
expect(create.disabled).toBe(true);
|
||||||
|
// Bytes of UTF-8, as felis-api counts: 342 characters, 1026 bytes.
|
||||||
|
fireEvent.change(command, { target: { value: "中".repeat(342) } });
|
||||||
|
expect(within(dialog).getByText("The command is longer than 1024 bytes.")).toBeTruthy();
|
||||||
|
fireEvent.change(command, { target: { value: "say hi" } });
|
||||||
|
|
||||||
|
// Characters, not UTF-16 units, and the surrounding space does not count.
|
||||||
|
const label = within(dialog).getByLabelText("Name (optional)");
|
||||||
|
fireEvent.change(label, { target: { value: ` ${"𠀀".repeat(64)} ` } });
|
||||||
|
expect(create.disabled).toBe(false);
|
||||||
|
fireEvent.change(label, { target: { value: "𠀀".repeat(65) } });
|
||||||
|
expect(within(dialog).getByText("The name is longer than 64 characters.")).toBeTruthy();
|
||||||
|
expect(create.disabled).toBe(true);
|
||||||
|
fireEvent.change(label, { target: { value: "" } });
|
||||||
|
|
||||||
|
const zone = within(dialog).getByLabelText("Time zone") as HTMLInputElement;
|
||||||
|
fireEvent.change(zone, { target: { value: "Mars/Olympus" } });
|
||||||
|
expect(within(dialog).getByText("Unknown time zone. Use a name such as Asia/Shanghai.")).toBeTruthy();
|
||||||
|
expect(create.disabled).toBe(true);
|
||||||
|
fireEvent.change(zone, { target: { value: " " } });
|
||||||
|
expect(within(dialog).getByText("Enter a time zone.")).toBeTruthy();
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Use the browser's zone" }));
|
||||||
|
expect(zone.value).toBe(BROWSER);
|
||||||
|
expect(create.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the dialog open with the server's reason when a save is refused", async () => {
|
||||||
|
calls.createSchedule.mockRejectedValue({
|
||||||
|
status: 409,
|
||||||
|
code: "schedule_limit",
|
||||||
|
message: "a server can have at most 20 scheduled tasks",
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
const dialog = await openCreate();
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
|
||||||
|
expect((await within(dialog).findByRole("alert")).textContent).toBe(
|
||||||
|
"This server already has as many scheduled tasks as it can hold. Delete one first.",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("dialog")).toBe(dialog);
|
||||||
|
expect((within(dialog).getByRole("button", { name: "Create" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a nightly backup from its template", async () => {
|
||||||
|
calls.createSchedule.mockResolvedValue(schedule({ id: 9, action: "backup" }));
|
||||||
|
renderPage();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Back up every night at 05:00" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(dialog).getByText(/A backup of a running server stops it/)).toBeTruthy();
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
|
||||||
|
expect(calls.createSchedule).toHaveBeenCalledWith("survival", {
|
||||||
|
label: "",
|
||||||
|
action: "backup",
|
||||||
|
command: "",
|
||||||
|
every_minutes: 0,
|
||||||
|
minute_of_day: 300,
|
||||||
|
weekdays: 127,
|
||||||
|
timezone: BROWSER,
|
||||||
|
warn_minutes: 5,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits a task and saves the whole of it", async () => {
|
||||||
|
listing([
|
||||||
|
schedule({ id: 4, label: "Ad", action: "command", command: "say hi", every_minutes: 120, minute_of_day: 0, weekdays: 65, timezone: OTHER, warn_minutes: 0 }),
|
||||||
|
]);
|
||||||
|
calls.updateSchedule.mockResolvedValue(schedule({ id: 4, label: "Advert" }));
|
||||||
|
renderPage();
|
||||||
|
await userEvent.click(within(await rowOf("Ad")).getByRole("button", { name: "Edit" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(dialog).getByText("Edit scheduled task")).toBeTruthy();
|
||||||
|
expect(within(dialog).getByRole("combobox", { name: "Repeat every" }).textContent).toBe("2 hours");
|
||||||
|
await userEvent.type(within(dialog).getByLabelText("Name (optional)"), "vert");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
expect(calls.updateSchedule).toHaveBeenCalledWith("survival", 4, {
|
||||||
|
label: "Advert",
|
||||||
|
action: "command",
|
||||||
|
command: "say hi",
|
||||||
|
every_minutes: 120,
|
||||||
|
minute_of_day: 0,
|
||||||
|
weekdays: 65,
|
||||||
|
timezone: OTHER,
|
||||||
|
warn_minutes: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("Saved “Advert”.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches a task off by saving all of it with enabled flipped", async () => {
|
||||||
|
listing([
|
||||||
|
schedule({ id: 4, label: "Nightly", minute_of_day: 330, weekdays: 42, timezone: OTHER, warn_minutes: 10 }),
|
||||||
|
]);
|
||||||
|
let answer: (s: Schedule) => void = () => {};
|
||||||
|
calls.updateSchedule.mockReturnValue(new Promise<Schedule>((resolve) => (answer = resolve)));
|
||||||
|
renderPage();
|
||||||
|
const toggle = (await screen.findByRole("switch", { name: "Run Nightly on schedule" })) as HTMLButtonElement;
|
||||||
|
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
expect(calls.updateSchedule).toHaveBeenCalledWith("survival", 4, {
|
||||||
|
label: "Nightly",
|
||||||
|
action: "restart",
|
||||||
|
command: "",
|
||||||
|
every_minutes: 0,
|
||||||
|
minute_of_day: 330,
|
||||||
|
weekdays: 42,
|
||||||
|
timezone: OTHER,
|
||||||
|
warn_minutes: 10,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
expect(toggle.disabled).toBe(true);
|
||||||
|
await act(async () => answer(schedule({ id: 4, enabled: false })));
|
||||||
|
expect(await screen.findByText("“Nightly” is off.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks before running a task now, and warns that a restart disconnects the players", async () => {
|
||||||
|
listing([
|
||||||
|
schedule({ id: 1, label: "Nightly" }),
|
||||||
|
schedule({ id: 2, label: "Hello", action: "command", command: "say hi", warn_minutes: 0 }),
|
||||||
|
]);
|
||||||
|
calls.runSchedule.mockResolvedValue(schedule({ id: 1, run_state: "stopping" }));
|
||||||
|
renderPage();
|
||||||
|
await userEvent.click(within(await rowOf("Nightly")).getByRole("button", { name: "Run now" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(dialog).getByText("Stops the server now and starts it again.")).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText(KICK)).toBeTruthy();
|
||||||
|
expect(calls.runSchedule).not.toHaveBeenCalled();
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Run now" }));
|
||||||
|
expect(calls.runSchedule).toHaveBeenCalledWith("survival", 1);
|
||||||
|
expect(await screen.findByText("Started “Nightly”.")).toBeTruthy();
|
||||||
|
|
||||||
|
await userEvent.click(within(await rowOf("Hello")).getByRole("button", { name: "Run now" }));
|
||||||
|
const second = await screen.findByRole("dialog");
|
||||||
|
expect(within(second).getByText("say hi", { selector: "code" })).toBeTruthy();
|
||||||
|
expect(within(second).queryByText(KICK)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a task once confirmed", async () => {
|
||||||
|
listing([schedule({ id: 5, label: "Old" })]);
|
||||||
|
calls.deleteSchedule.mockResolvedValue(null);
|
||||||
|
renderPage();
|
||||||
|
await userEvent.click(within(await rowOf("Old")).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(dialog).getByText("Delete “Old”?")).toBeTruthy();
|
||||||
|
expect(calls.deleteSchedule).not.toHaveBeenCalled();
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||||
|
expect(calls.deleteSchedule).toHaveBeenCalledWith("survival", 5);
|
||||||
|
expect(await screen.findByText("Deleted “Old”.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks a task while it runs, and says why", async () => {
|
||||||
|
// A claim stamps last_run_at with the run's start and clears the result.
|
||||||
|
const started = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||||
|
listing([
|
||||||
|
schedule({ id: 1, label: "Busy", run_state: "stopping", last_run_at: started }),
|
||||||
|
schedule({ id: 2, label: "Idle" }),
|
||||||
|
]);
|
||||||
|
renderPage();
|
||||||
|
const busy = await rowOf("Busy");
|
||||||
|
expect(within(busy).getByText("Stopping the server…")).toBeTruthy();
|
||||||
|
expect(within(busy).getByText("Started 5 minutes ago")).toBeTruthy();
|
||||||
|
expect(within(busy).queryByText("Hasn't run yet")).toBeNull();
|
||||||
|
for (const name of ["Run now", "Edit", "Delete"]) {
|
||||||
|
const button = within(busy).getByRole("button", { name }) as HTMLButtonElement;
|
||||||
|
expect(button.disabled).toBe(true);
|
||||||
|
expect(button.parentElement?.title).toBe("This task is running. Wait for the run to finish.");
|
||||||
|
}
|
||||||
|
expect((within(busy).getByRole("switch") as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
|
||||||
|
const idle = await rowOf("Idle");
|
||||||
|
for (const name of ["Run now", "Edit", "Delete"]) {
|
||||||
|
expect((within(idle).getByRole("button", { name }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
}
|
||||||
|
expect((within(idle).getByRole("switch") as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no new task once the server holds its limit", async () => {
|
||||||
|
listing([schedule({ id: 1, label: "One" }), schedule({ id: 2, label: "Two" })], 2);
|
||||||
|
renderPage();
|
||||||
|
const add = (await screen.findByRole("button", { name: "New task" })) as HTMLButtonElement;
|
||||||
|
await rowOf("One");
|
||||||
|
expect(add.disabled).toBe(true);
|
||||||
|
expect(add.parentElement?.title).toBe("A server holds at most 2 tasks. Delete one to add another.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says plainly when felis-api has no schedule store, and treats other failures as errors", async () => {
|
||||||
|
calls.listSchedules.mockRejectedValue({ status: 503, code: "schedules_unavailable", message: "not configured" });
|
||||||
|
const first = renderPage();
|
||||||
|
expect(await screen.findByText("Scheduled tasks aren't available")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "New task" })).toBeNull();
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
calls.listSchedules.mockRejectedValue({ status: 500, code: "internal", message: "boom" });
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Scheduled tasks aren't available")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rereads fast while a run is in progress", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
listing([schedule({ id: 1, label: "Backup", action: "backup", run_state: "backing_up" })]);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Backing up…");
|
||||||
|
const reads = calls.listSchedules.mock.calls.length;
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS + 100));
|
||||||
|
expect(calls.listSchedules.mock.calls.length).toBe(reads + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rereads slowly while nothing runs", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
listing([schedule({ id: 1, label: "Nightly" })]);
|
||||||
|
renderPage();
|
||||||
|
await rowOf("Nightly");
|
||||||
|
const reads = calls.listSchedules.mock.calls.length;
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS + 100));
|
||||||
|
expect(calls.listSchedules.mock.calls.length).toBe(reads);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_SLOW_MS - STATUS_POLL_FAST_MS));
|
||||||
|
expect(calls.listSchedules.mock.calls.length).toBe(reads + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user