fix: address final review findings for master/agent phase 1 (#6)
- inventory: resolve the image reference via ContainerInspect when a moved tag makes ContainerList report the image ID - store: allow reusing a revoked agent's name (partial unique index) and mark the revoked agent's compose/container services removed - web: publish a services event on revoke; no-store on the one-time token page - webauth: reject empty OIDC state; accept the CSRF form token from the body only - agent conn: bound the WebSocket handshake to 30s - hub: log agent_replaced when a connection replaces an older one
This commit is contained in:
@@ -24,7 +24,8 @@ func scanAgent(row pgx.Row) (model.Agent, error) {
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAgent registers an agent. Returns model.ErrConflict if the name is taken.
|
||||
// CreateAgent registers an agent. Returns model.ErrConflict if an active
|
||||
// (non-revoked) agent already has the name.
|
||||
func (s *Store) CreateAgent(ctx context.Context, name, tokenHash string) (model.Agent, error) {
|
||||
a, err := scanAgent(s.pool.QueryRow(ctx,
|
||||
`INSERT INTO agents (name, token_hash) VALUES ($1, $2) RETURNING `+agentColumns, name, tokenHash))
|
||||
@@ -62,13 +63,29 @@ func (s *Store) AgentByTokenHash(ctx context.Context, hash string) (model.Agent,
|
||||
return scanAgent(s.pool.QueryRow(ctx, `SELECT `+agentColumns+` FROM agents WHERE token_hash = $1`, hash))
|
||||
}
|
||||
|
||||
// RevokeAgent marks the agent revoked. Its token stops working immediately;
|
||||
// closing a live connection is the hub's job.
|
||||
// RevokeAgent marks the agent revoked and its Compose and plain-container
|
||||
// services removed, in one transaction. Swarm rows are shared with other
|
||||
// managers and left alone. Its token stops working immediately; closing a
|
||||
// live connection is the hub's job.
|
||||
func (s *Store) RevokeAgent(ctx context.Context, id int64, at time.Time) error {
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke agent: begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE agents SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`, id, at); err != nil {
|
||||
return fmt.Errorf("revoke agent: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE services SET removed_at = $2
|
||||
WHERE agent_id = $1 AND kind IN ('compose', 'container') AND removed_at IS NULL`, id, at); err != nil {
|
||||
return fmt.Errorf("revoke agent: remove services: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("revoke agent: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -82,3 +82,31 @@ func TestAgents_RecordHelloAndMarkSeen(t *testing.T) {
|
||||
require.NotNil(t, agents[0].LastSeenAt)
|
||||
assert.True(t, agents[0].LastSeenAt.Equal(at.Add(time.Minute)))
|
||||
}
|
||||
|
||||
func TestAgents_RevokedNameCanBeReused(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
old, err := s.CreateAgent(ctx, "node-2", "h1")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.RevokeAgent(ctx, old.ID, time.Now()))
|
||||
|
||||
fresh, err := s.CreateAgent(ctx, "node-2", "h2")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, old.ID, fresh.ID)
|
||||
|
||||
_, err = s.CreateAgent(ctx, "node-2", "h3")
|
||||
assert.ErrorIs(t, err, model.ErrConflict, "a second active agent with the name is still a conflict")
|
||||
}
|
||||
|
||||
func TestAgents_RevokeRemovesOwnComposeAndContainerServices(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
id := newAgent(t, s, "swarm-mgr")
|
||||
require.NoError(t, s.ApplySnapshot(ctx, id, manager, []protocol.Service{slusk, adhoc, gitea}, t0))
|
||||
|
||||
require.NoError(t, s.RevokeAgent(ctx, id, t0.Add(time.Hour)))
|
||||
|
||||
got, err := s.ListServices(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"gitea_gitea"}, names(got), "swarm rows are shared and must survive a revoke")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ CREATE TABLE master_identity (
|
||||
|
||||
CREATE TABLE agents (
|
||||
id bigserial PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
hostname text NOT NULL DEFAULT '',
|
||||
agent_version text NOT NULL DEFAULT '',
|
||||
@@ -16,6 +16,9 @@ CREATE TABLE agents (
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- A revoked agent's name can be reused by a new agent.
|
||||
CREATE UNIQUE INDEX agents_name_active ON agents (name) WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE services (
|
||||
id bigserial PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('compose', 'swarm', 'container')),
|
||||
|
||||
Reference in New Issue
Block a user