feat(protocol): job messages, protocol version and job model (#7)
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
// open browser streams.
|
||||
package events
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Topic names what changed. Subscribers refetch that part of the UI.
|
||||
type Topic string
|
||||
@@ -11,8 +14,13 @@ type Topic string
|
||||
const (
|
||||
Services Topic = "services"
|
||||
Agents Topic = "agents"
|
||||
Jobs Topic = "jobs" // a job was created or changed status
|
||||
)
|
||||
|
||||
// JobTopic is signalled when job id gets new output or changes status. The
|
||||
// name avoids ":" so it is safe inside hx-trigger="sse:job-42".
|
||||
func JobTopic(id int64) Topic { return Topic("job-" + strconv.FormatInt(id, 10)) }
|
||||
|
||||
// Broker is an in-process publish/subscribe hub.
|
||||
type Broker struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -46,3 +46,7 @@ func TestBroker_SlowSubscriberDoesNotBlockPublish(t *testing.T) {
|
||||
t.Fatal("Publish blocked on a slow subscriber")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobTopic(t *testing.T) {
|
||||
assert.Equal(t, Topic("job-42"), JobTopic(42))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors for jobs.
|
||||
var (
|
||||
// ErrJobActive means the service already has a queued or running job.
|
||||
// CreateJob and jobs.Service.Trigger return it together with that job.
|
||||
ErrJobActive = errors.New("an update is already in progress for this service")
|
||||
// ErrAgentOffline means no connected agent can run the job.
|
||||
ErrAgentOffline = errors.New("agent offline")
|
||||
// ErrAgentTooOld means the only agent that could run the job speaks a
|
||||
// protocol version without jobs.
|
||||
ErrAgentTooOld = errors.New("agent too old for updates")
|
||||
)
|
||||
|
||||
// NotUpdatableError says why a service cannot be updated. Reason is shown
|
||||
// in the UI as is.
|
||||
type NotUpdatableError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *NotUpdatableError) Error() string { return "not updatable: " + e.Reason }
|
||||
|
||||
// Reasons a service cannot be updated.
|
||||
const (
|
||||
ReasonSelf = "updated manually outside the system"
|
||||
ReasonContainer = "plain containers cannot be updated"
|
||||
ReasonNoTag = "follows no tag"
|
||||
ReasonRemoved = "service no longer exists"
|
||||
)
|
||||
|
||||
// JobStatus is the state of an update job.
|
||||
type JobStatus string
|
||||
|
||||
// Job statuses. A job moves queued -> running -> succeeded|failed, or
|
||||
// straight from queued to failed.
|
||||
const (
|
||||
JobQueued JobStatus = "queued"
|
||||
JobRunning JobStatus = "running"
|
||||
JobSucceeded JobStatus = "succeeded"
|
||||
JobFailed JobStatus = "failed"
|
||||
)
|
||||
|
||||
// JobTrigger says what created a job. Only TriggerManual is used in phase 2.
|
||||
type JobTrigger string
|
||||
|
||||
// Job triggers.
|
||||
const (
|
||||
TriggerManual JobTrigger = "manual"
|
||||
TriggerWebhook JobTrigger = "webhook"
|
||||
TriggerPoll JobTrigger = "poll"
|
||||
)
|
||||
|
||||
// Job is one update of one service.
|
||||
type Job struct {
|
||||
ID int64
|
||||
ServiceID int64
|
||||
ServiceName string // Service.DisplayName() of the job's service
|
||||
AgentID *int64 // agent the job was sent to; nil if it never was
|
||||
AgentName string
|
||||
FromRef string
|
||||
ToRef string // "repo:tag" when created; "repo:tag@digest" after success
|
||||
Trigger JobTrigger
|
||||
RequestedBy string // OIDC subject for manual jobs
|
||||
Status JobStatus
|
||||
Output string // empty in ListJobs results
|
||||
OutputTruncated bool
|
||||
Error string
|
||||
CreatedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
// Active reports whether the job is queued or running.
|
||||
func (j Job) Active() bool { return j.Status == JobQueued || j.Status == JobRunning }
|
||||
|
||||
// NewJob is what CreateJob needs to insert a queued job.
|
||||
type NewJob struct {
|
||||
ServiceID int64
|
||||
AgentID *int64
|
||||
FromRef string
|
||||
ToRef string
|
||||
Trigger JobTrigger
|
||||
RequestedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// JobFinish is a job's outcome as reported by the agent.
|
||||
type JobFinish struct {
|
||||
Status JobStatus // JobSucceeded or JobFailed
|
||||
Ref string // deployed "repo:tag@digest"; set on success
|
||||
Error string
|
||||
Output string
|
||||
OutputTruncated bool
|
||||
StartedAt time.Time // agent clock
|
||||
FinishedAt time.Time // agent clock
|
||||
}
|
||||
|
||||
// JobFilter selects jobs for ListJobs. ServiceID 0 means all services.
|
||||
// Limit 0 means 100.
|
||||
type JobFilter struct {
|
||||
ServiceID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Route is the hub's choice of agent connection for a job. ConnID identifies
|
||||
// the connection, so a job is never sent over a connection that replaced the
|
||||
// one chosen.
|
||||
type Route struct {
|
||||
AgentID int64
|
||||
ConnID uint64
|
||||
}
|
||||
@@ -41,10 +41,12 @@ type Service struct {
|
||||
ComposeProject string
|
||||
ComposeService string
|
||||
WorkingDir string
|
||||
ConfigFiles string // raw config_files label, paths joined by ","
|
||||
SwarmClusterID string
|
||||
SwarmService string
|
||||
ContainerName string
|
||||
ImageRepo string
|
||||
DesiredRef string // set by the last successful job; "" if never updated
|
||||
ActualRef string
|
||||
ActualDigest string
|
||||
Status string
|
||||
@@ -52,6 +54,7 @@ type Service struct {
|
||||
UpdateMode string
|
||||
FirstSeenAt time.Time
|
||||
UpdatedAt time.Time
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
|
||||
// DisplayName is the human-facing name of the service.
|
||||
|
||||
@@ -39,3 +39,20 @@ func TestDisplayName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_Active(t *testing.T) {
|
||||
cases := []struct {
|
||||
status JobStatus
|
||||
want bool
|
||||
}{
|
||||
{JobQueued, true},
|
||||
{JobRunning, true},
|
||||
{JobSucceeded, false},
|
||||
{JobFailed, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(string(c.status), func(t *testing.T) {
|
||||
assert.Equal(t, c.want, Job{Status: c.status}.Active())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE services ADD COLUMN config_files text NOT NULL DEFAULT '';
|
||||
ALTER TABLE services ADD COLUMN desired_ref text NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE update_jobs (
|
||||
id bigserial PRIMARY KEY,
|
||||
service_id bigint NOT NULL REFERENCES services (id),
|
||||
agent_id bigint REFERENCES agents (id),
|
||||
from_ref text NOT NULL DEFAULT '',
|
||||
to_ref text NOT NULL,
|
||||
trigger text NOT NULL CHECK (trigger IN ('manual', 'webhook', 'poll')),
|
||||
requested_by text NOT NULL DEFAULT '',
|
||||
status text NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
|
||||
output text NOT NULL DEFAULT '',
|
||||
output_truncated boolean NOT NULL DEFAULT false,
|
||||
error text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz
|
||||
);
|
||||
|
||||
-- One queued or running job per service. CreateJob relies on this index to
|
||||
-- turn a second concurrent click into ErrJobActive.
|
||||
CREATE UNIQUE INDEX update_jobs_one_active ON update_jobs (service_id)
|
||||
WHERE status IN ('queued', 'running');
|
||||
CREATE INDEX update_jobs_by_service ON update_jobs (service_id, id DESC);
|
||||
CREATE INDEX update_jobs_active_by_agent ON update_jobs (agent_id)
|
||||
WHERE status IN ('queued', 'running');
|
||||
Reference in New Issue
Block a user