mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(nodes): remote ingress via Traefik file provider
Remote replicas publish an ephemeral host port; the agent reports the assigned binding back in the create_container result and the backend renders a per-service file-provider config into TRAEFIK_DYNAMIC_DIR pointing Traefik at the node's mesh IP + port. Routes are rewritten on every remote reconcile and removed on teardown or local placement. Also: RetireService retires remote leftovers when a service returns to local placement; remove_container is idempotent agent-side; tombstoned replicas skip remove dispatch; the domains-stay-local ceiling is gone. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
4458de56b0
commit
c3a35081d0
13 files changed
+374
-39
No files matched your search
@@ -49,6 +49,10 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
|||||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
||||||
# Bundled agent binaries dir served at /api/agents/download/:platform.
|
# Bundled agent binaries dir served at /api/agents/download/:platform.
|
||||||
# CONTAINR_AGENT_BIN_DIR=/agents
|
# CONTAINR_AGENT_BIN_DIR=/agents
|
||||||
|
# Directory Traefik's file provider watches. The backend writes one
|
||||||
|
# config per remote service here so domains route to node agents. The
|
||||||
|
# infra compose wires a shared volume — set only for custom layouts.
|
||||||
|
# TRAEFIK_DYNAMIC_DIR=/traefik-dynamic
|
||||||
# Optional remote template catalog (another Containr instance). Its
|
# Optional remote template catalog (another Containr instance). Its
|
||||||
# templates merge into GET /templates as source:"registry" and
|
# templates merge into GET /templates as source:"registry" and
|
||||||
# materialize locally on deploy. The bundled catalog works without it.
|
# materialize locally on deploy. The bundled catalog works without it.
|
||||||
|
|||||||
+9
-3
@@ -296,9 +296,15 @@ and wired refs, one click or one CLI call.
|
|||||||
utilisation, then cpu, then container count, with uninstrumented
|
utilisation, then cpu, then container count, with uninstrumented
|
||||||
agents sorting last. Lifecycle and `GET /runtime` are
|
agents sorting last. Lifecycle and `GET /runtime` are
|
||||||
inventory-driven, so remote replicas show up and tear down wherever
|
inventory-driven, so remote replicas show up and tear down wherever
|
||||||
they run. Remote nodes run registry-pulled
|
they run. Remote ingress shipped: the agent reports assigned host
|
||||||
images only — git builds, Traefik domains, and remote sleep are
|
ports in the `create_container` result, the backend renders a
|
||||||
documented ceilings. Tag affinity shipped: `node_agents.tags`
|
Traefik file-provider config per remote service into
|
||||||
|
`TRAEFIK_DYNAMIC_DIR` (shared volume, `watch=true`) pointing the
|
||||||
|
loadbalancer at each replica's mesh IP + published port; route files
|
||||||
|
are rewritten on every remote reconcile and removed on teardown or
|
||||||
|
local placement. Remote nodes run registry-pulled
|
||||||
|
images only — git builds and remote sleep are documented ceilings.
|
||||||
|
Tag affinity shipped: `node_agents.tags`
|
||||||
(operator-set via `PUT /agents/:id` / `nodes update --tags`) +
|
(operator-set via `PUT /agents/:id` / `nodes update --tags`) +
|
||||||
`services.placement_tags` — `auto` and `spread` only consider
|
`services.placement_tags` — `auto` and `spread` only consider
|
||||||
online schedulable agents carrying every required tag, tags are
|
online schedulable agents carrying every required tag, tags are
|
||||||
|
|||||||
@@ -272,7 +272,12 @@ func executeCommand(item command) (string, error) {
|
|||||||
case "restart_container":
|
case "restart_container":
|
||||||
return runDocker("restart", commandTarget(item.Payload))
|
return runDocker("restart", commandTarget(item.Payload))
|
||||||
case "remove_container":
|
case "remove_container":
|
||||||
return runDocker("rm", "-f", commandTarget(item.Payload))
|
out, err := runDocker("rm", "-f", commandTarget(item.Payload))
|
||||||
|
// A missing container is the desired end state — report success.
|
||||||
|
if err != nil && strings.Contains(out, "No such container") {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
case "prune":
|
case "prune":
|
||||||
return dockerPrune(item.Payload)
|
return dockerPrune(item.Payload)
|
||||||
case "system_df":
|
case "system_df":
|
||||||
@@ -460,7 +465,25 @@ func createContainer(payload map[string]interface{}) (string, error) {
|
|||||||
logs, _ := runDocker("logs", "--tail", "5", name)
|
logs, _ := runDocker("logs", "--tail", "5", name)
|
||||||
return "", fmt.Errorf("container %s is %q after start: %s", name, state, logs)
|
return "", fmt.Errorf("container %s is %q after start: %s", name, state, logs)
|
||||||
}
|
}
|
||||||
return name, nil
|
// Report the real host port bindings — ephemeral publishes pick a port
|
||||||
|
// the server can't guess, and remote ingress needs the actual mapping.
|
||||||
|
hostPorts := map[string]string{}
|
||||||
|
portsJSON, _ := runDocker("inspect", "-f", "{{json .NetworkSettings.Ports}}", name)
|
||||||
|
var bindings map[string][]struct {
|
||||||
|
HostIP string `json:"HostIp"`
|
||||||
|
HostPort string `json:"HostPort"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(portsJSON), &bindings) == nil {
|
||||||
|
for cport, addrs := range bindings {
|
||||||
|
for _, a := range addrs {
|
||||||
|
if a.HostPort != "" && a.HostPort != "0" {
|
||||||
|
hostPorts[cport] = a.HostPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(map[string]interface{}{"name": name, "host_ports": hostPorts})
|
||||||
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// registryHostOf mirrors the server's registryHost: first path segment with
|
// registryHostOf mirrors the server's registryHost: first path segment with
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Remote ingress: replicas on node agents aren't on the hub's
|
||||||
|
// containr-network, so Traefik's Docker provider can't see them. Instead
|
||||||
|
// the backend renders one file-provider config per service into
|
||||||
|
// TRAEFIK_DYNAMIC_DIR (a directory Traefik watches), pointing the
|
||||||
|
// loadbalancer at the node's reachable IP + the ephemeral host port the
|
||||||
|
// agent reported back after `create_container`.
|
||||||
|
//
|
||||||
|
// Empty dir → feature off (bare-metal dev). Files are best-effort: a
|
||||||
|
// failed write never fails a reconcile.
|
||||||
|
|
||||||
|
func ingressDir() string { return os.Getenv("TRAEFIK_DYNAMIC_DIR") }
|
||||||
|
|
||||||
|
func ingressFile(serviceID string) string {
|
||||||
|
return filepath.Join(ingressDir(), "containr-"+serviceID+".yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonInt coerces JSON-ish numbers (int/int32/int64/float64) to int —
|
||||||
|
// spec fields arrive as int32, agent payloads unmarshal as float64.
|
||||||
|
func jsonInt(v interface{}) int {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case int32:
|
||||||
|
return int(n)
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedHostPorts parses the agent's create_container result —
|
||||||
|
// {"name": ..., "host_ports": {"80/tcp": "32768"}}. Older agents return a
|
||||||
|
// bare name string; JSON parse failure means no port info.
|
||||||
|
func reportedHostPorts(result string) map[string]string {
|
||||||
|
var out struct {
|
||||||
|
HostPorts map[string]string `json:"host_ports"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(result), &out) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out.HostPorts
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeIngressIP picks the address the hub can reach the node on. Mesh
|
||||||
|
// overlays first — the agent operator joined a mesh precisely so the hub
|
||||||
|
// can reach it — then the reported primary IP as a LAN fallback.
|
||||||
|
func nodeIngressIP(agent sqlcdb.NodeAgent) string {
|
||||||
|
var meta struct {
|
||||||
|
Mesh map[string]string `json:"mesh"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(agent.Metadata.RawMessage, &meta)
|
||||||
|
for _, prefix := range []string{"tailscale", "netbird", "wg", "zt"} {
|
||||||
|
var keys []string
|
||||||
|
for name := range meta.Mesh {
|
||||||
|
keys = append(keys, name)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, name := range keys {
|
||||||
|
if strings.HasPrefix(name, prefix) && meta.Mesh[name] != "" {
|
||||||
|
return meta.Mesh[name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agent.IpAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncRemoteIngress rewrites the service's file-provider config from the
|
||||||
|
// live remote replica set. Called after every remote reconcile so replica
|
||||||
|
// adds/removals and node retargets converge the upstream list.
|
||||||
|
func syncRemoteIngress(ctx context.Context, db *database.DB, spec deployment.RuntimeSpec) {
|
||||||
|
if ingressDir() == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domains := spec.Domains
|
||||||
|
if len(domains) == 0 && spec.Domain != "" {
|
||||||
|
domains = []string{spec.Domain}
|
||||||
|
}
|
||||||
|
if len(domains) == 0 || spec.Port <= 0 {
|
||||||
|
removeServiceIngress(spec.ServiceID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := sqlcdb.New(db.DB)
|
||||||
|
rows, err := q.ListServiceContainers(ctx, spec.ServiceID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type upstream struct{ ip, port string }
|
||||||
|
var servers []upstream
|
||||||
|
for _, row := range rows {
|
||||||
|
var st struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(row.Status.RawMessage, &st)
|
||||||
|
if st.State != "running" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var port string
|
||||||
|
var plist []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(row.Ports.RawMessage, &plist); err == nil {
|
||||||
|
for _, p := range plist {
|
||||||
|
if hp, ok := p["host_port"].(float64); ok && hp > 0 {
|
||||||
|
port = fmt.Sprintf("%d", int(hp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agent, err := q.GetAgent(ctx, row.NodeAgentID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip := nodeIngressIP(agent); ip != "" {
|
||||||
|
servers = append(servers, upstream{ip, port})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(servers) == 0 {
|
||||||
|
removeServiceIngress(spec.ServiceID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router := "svc-" + spec.ServiceID[:8]
|
||||||
|
rules := make([]string, 0, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
rules = append(rules, "Host(`"+d+"`)")
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("http:\n routers:\n " + router + ":\n")
|
||||||
|
b.WriteString(" rule: \"" + strings.Join(rules, " || ") + "\"\n")
|
||||||
|
b.WriteString(" entrypoints: [web]\n")
|
||||||
|
b.WriteString(" service: " + router + "\n")
|
||||||
|
b.WriteString(" services:\n " + router + ":\n loadBalancer:\n servers:\n")
|
||||||
|
for _, s := range servers {
|
||||||
|
b.WriteString(" - url: \"http://" + s.ip + ":" + s.port + "\"\n")
|
||||||
|
}
|
||||||
|
// Atomic-ish: write temp then rename so Traefik's watcher never reads a
|
||||||
|
// truncated file.
|
||||||
|
tmp := ingressFile(spec.ServiceID) + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Rename(tmp, ingressFile(spec.ServiceID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeServiceIngress deletes the service's remote route. Called on
|
||||||
|
// remote teardown and when a service returns to local placement.
|
||||||
|
func removeServiceIngress(serviceID string) {
|
||||||
|
if ingressDir() == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Remove(ingressFile(serviceID))
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
|
|
||||||
// agentNodeRunner implements deployment.NodeRunner by dispatching container
|
// agentNodeRunner implements deployment.NodeRunner by dispatching container
|
||||||
// lifecycle commands to a node agent's command queue and waiting for the
|
// lifecycle commands to a node agent's command queue and waiting for the
|
||||||
// reported result. Remote nodes have no Traefik — domains stay local-only
|
// reported result. Domains route through the file provider —
|
||||||
// until per-node ingress lands.
|
// syncRemoteIngress renders per-service configs from live inventory.
|
||||||
type agentNodeRunner struct {
|
type agentNodeRunner struct {
|
||||||
q *sqlcdb.Queries
|
q *sqlcdb.Queries
|
||||||
db *database.DB
|
db *database.DB
|
||||||
@@ -131,10 +131,6 @@ func (r *agentNodeRunner) RemoteRuntimeState(ctx context.Context, serviceID stri
|
|||||||
// targetFor and retires stale replicas on any agent — including leftovers
|
// targetFor and retires stale replicas on any agent — including leftovers
|
||||||
// from a previous pin or a different spread assignment.
|
// from a previous pin or a different spread assignment.
|
||||||
func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment.RuntimeSpec, targetFor func(replica int) string) (*deployment.RuntimeState, error) {
|
func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment.RuntimeSpec, targetFor func(replica int) string) (*deployment.RuntimeState, error) {
|
||||||
if len(spec.Domains) > 0 || spec.Domain != "" {
|
|
||||||
return nil, fmt.Errorf("domains are routed by the local Traefik — remove the service's domains or run it on the local node")
|
|
||||||
}
|
|
||||||
|
|
||||||
replicas := spec.Replicas
|
replicas := spec.Replicas
|
||||||
if replicas < 1 {
|
if replicas < 1 {
|
||||||
replicas = 1
|
replicas = 1
|
||||||
@@ -151,7 +147,7 @@ func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment
|
|||||||
if keep {
|
if keep {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if cmdErr := r.enqueueAndWait(ctx, row.NodeAgentID, "", "remove_container",
|
if _, cmdErr := r.enqueueAndWait(ctx, row.NodeAgentID, "", "remove_container",
|
||||||
map[string]interface{}{"container_name": row.Name}); cmdErr != nil {
|
map[string]interface{}{"container_name": row.Name}); cmdErr != nil {
|
||||||
return nil, fmt.Errorf("remove stale replica %s on node %s: %w", row.Name, row.NodeAgentID, cmdErr)
|
return nil, fmt.Errorf("remove stale replica %s on node %s: %w", row.Name, row.NodeAgentID, cmdErr)
|
||||||
}
|
}
|
||||||
@@ -185,18 +181,41 @@ func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment
|
|||||||
HealthCheck: rawJSON(map[string]interface{}{"path": spec.HealthPath}),
|
HealthCheck: rawJSON(map[string]interface{}{"path": spec.HealthPath}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := r.enqueueAndWait(ctx, agentID, containerID, "create_container",
|
result, err := r.enqueueAndWait(ctx, agentID, containerID, "create_container",
|
||||||
map[string]interface{}{"container": container}); err != nil {
|
map[string]interface{}{"container": container})
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("replica %d on node %s: %w", i, agentID, err)
|
return nil, fmt.Errorf("replica %d on node %s: %w", i, agentID, err)
|
||||||
}
|
}
|
||||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
if assigned := reportedHostPorts(result); len(assigned) > 0 {
|
||||||
ID: containerID,
|
for _, p := range ports {
|
||||||
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
|
pm, ok := p.(map[string]interface{})
|
||||||
})
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cport := jsonInt(pm["container_port"])
|
||||||
|
proto, _ := pm["protocol"].(string)
|
||||||
|
if hp, ok := assigned[fmt.Sprintf("%d/%s", cport, proto)]; ok {
|
||||||
|
if n, err := strconv.Atoi(hp); err == nil {
|
||||||
|
pm["host_port"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = r.q.UpdateContainerPorts(ctx, sqlcdb.UpdateContainerPortsParams{
|
||||||
|
ID: containerID,
|
||||||
|
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
|
||||||
|
Ports: rawJSON(ports),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||||
|
ID: containerID,
|
||||||
|
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
|
||||||
|
})
|
||||||
|
}
|
||||||
state.Containers = append(state.Containers, deployment.RuntimeContainer{
|
state.Containers = append(state.Containers, deployment.RuntimeContainer{
|
||||||
ID: containerID, Name: name, State: "running", Replica: i,
|
ID: containerID, Name: name, State: "running", Replica: i,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
syncRemoteIngress(ctx, r.db, spec)
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +315,17 @@ func (r *agentNodeRunner) ControlService(ctx context.Context, serviceID, agentID
|
|||||||
if row.ServiceID != serviceID {
|
if row.ServiceID != serviceID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := r.enqueueAndWait(ctx, agentID, row.ID, cmdType,
|
// Tombstoned replicas are already gone remotely — skip the dispatch
|
||||||
|
// or a fresh docker rm on a missing name reports a false failure.
|
||||||
|
if action == "remove" {
|
||||||
|
var st struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(row.Status.RawMessage, &st); err == nil && st.State == "removed" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := r.enqueueAndWait(ctx, agentID, row.ID, cmdType,
|
||||||
map[string]interface{}{"container_name": row.Name}); err != nil {
|
map[string]interface{}{"container_name": row.Name}); err != nil {
|
||||||
return fmt.Errorf("%s %s on node: %w", action, row.Name, err)
|
return fmt.Errorf("%s %s on node: %w", action, row.Name, err)
|
||||||
}
|
}
|
||||||
@@ -314,16 +343,38 @@ func (r *agentNodeRunner) RemoveService(ctx context.Context, serviceID, agentID
|
|||||||
if err := r.ControlService(ctx, serviceID, agentID, "remove"); err != nil {
|
if err := r.ControlService(ctx, serviceID, agentID, "remove"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{
|
if err := r.q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{
|
||||||
NodeAgentID: agentID,
|
NodeAgentID: agentID,
|
||||||
ServiceID: serviceID,
|
ServiceID: serviceID,
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
removeServiceIngress(serviceID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetireService removes the service's remote replicas wherever they run —
|
||||||
|
// called when the service converges to local placement so a cleared pin or
|
||||||
|
// removed tag requirement doesn't leave containers behind.
|
||||||
|
func (r *agentNodeRunner) RetireService(ctx context.Context, serviceID string) error {
|
||||||
|
agentIDs, err := r.q.ListServiceAgents(ctx, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
if err := r.RemoveService(ctx, serviceID, agentID); err != nil {
|
||||||
|
return fmt.Errorf("retire on node %s: %w", agentID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removeServiceIngress(serviceID)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueAndWait pushes a command to the agent's queue and polls for the
|
// enqueueAndWait pushes a command to the agent's queue and polls for the
|
||||||
// reported result. Agents poll on their own interval (≥5s), so this blocks —
|
// reported result, returning the agent's result string on success. Agents
|
||||||
// it runs inside the deploy queue worker, never on a request path.
|
// poll on their own interval (≥5s), so this blocks — it runs inside the
|
||||||
func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}) error {
|
// deploy queue worker, never on a request path.
|
||||||
|
func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}) (string, error) {
|
||||||
var cid sql.NullString
|
var cid sql.NullString
|
||||||
if containerID != "" {
|
if containerID != "" {
|
||||||
cid = sql.NullString{String: containerID, Valid: true}
|
cid = sql.NullString{String: containerID, Valid: true}
|
||||||
@@ -336,7 +387,7 @@ func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, container
|
|||||||
Payload: rawJSON(payload),
|
Payload: rawJSON(payload),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline := time.Now().Add(8 * time.Minute) // image pulls on a cold node
|
deadline := time.Now().Add(8 * time.Minute) // image pulls on a cold node
|
||||||
@@ -348,20 +399,20 @@ func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, container
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
switch row.Status.String {
|
switch row.Status.String {
|
||||||
case "completed":
|
case "completed":
|
||||||
return nil
|
return row.Result.String, nil
|
||||||
case "failed":
|
case "failed":
|
||||||
if row.Error.Valid && row.Error.String != "" {
|
if row.Error.Valid && row.Error.String != "" {
|
||||||
return fmt.Errorf("agent: %s", row.Error.String)
|
return "", fmt.Errorf("agent: %s", row.Error.String)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("agent reported failure")
|
return "", fmt.Errorf("agent reported failure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
return fmt.Errorf("command %s timed out waiting for the agent", cmdType)
|
return "", fmt.Errorf("command %s timed out waiting for the agent", cmdType)
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return "", ctx.Err()
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -765,8 +765,36 @@ func (q *Queries) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 js
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listServiceAgents = `-- name: ListServiceAgents :many
|
||||||
|
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Distinct agents holding inventory rows for a service.
|
||||||
|
func (q *Queries) ListServiceAgents(ctx context.Context, serviceID string) ([]string, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listServiceAgents, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var node_agent_id string
|
||||||
|
if err := rows.Scan(&node_agent_id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, node_agent_id)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listServiceContainers = `-- name: ListServiceContainers :many
|
const listServiceContainers = `-- name: ListServiceContainers :many
|
||||||
SELECT id, name, node_agent_id, status FROM container_instances
|
SELECT id, name, node_agent_id, status, ports FROM container_instances
|
||||||
WHERE service_id = $1
|
WHERE service_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -775,6 +803,7 @@ type ListServiceContainersRow struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
NodeAgentID string `json:"node_agent_id"`
|
NodeAgentID string `json:"node_agent_id"`
|
||||||
Status pqtype.NullRawMessage `json:"status"`
|
Status pqtype.NullRawMessage `json:"status"`
|
||||||
|
Ports pqtype.NullRawMessage `json:"ports"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) {
|
func (q *Queries) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) {
|
||||||
@@ -791,6 +820,7 @@ func (q *Queries) ListServiceContainers(ctx context.Context, serviceID string) (
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.NodeAgentID,
|
&i.NodeAgentID,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
|
&i.Ports,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1035,24 @@ func (q *Queries) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeart
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateContainerPorts = `-- name: UpdateContainerPorts :exec
|
||||||
|
UPDATE container_instances SET status = $2, ports = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateContainerPortsParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status pqtype.NullRawMessage `json:"status"`
|
||||||
|
Ports pqtype.NullRawMessage `json:"ports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent reports the real host bindings after create — persist them with
|
||||||
|
// the status flip so ingress can route to the assigned port.
|
||||||
|
func (q *Queries) UpdateContainerPorts(ctx context.Context, arg UpdateContainerPortsParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateContainerPorts, arg.ID, arg.Status, arg.Ports)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const updateContainerStatus = `-- name: UpdateContainerStatus :exec
|
const updateContainerStatus = `-- name: UpdateContainerStatus :exec
|
||||||
UPDATE container_instances SET status = $2, updated_at = NOW()
|
UPDATE container_instances SET status = $2, updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ type Querier interface {
|
|||||||
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
|
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
|
||||||
// Only agents carrying every required placement tag (jsonb array containment).
|
// Only agents carrying every required placement tag (jsonb array containment).
|
||||||
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
|
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
|
||||||
|
// Distinct agents holding inventory rows for a service.
|
||||||
|
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
|
||||||
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
|
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
|
||||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||||
@@ -134,6 +136,9 @@ type Querier interface {
|
|||||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||||
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
||||||
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
|
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
|
||||||
|
// Agent reports the real host bindings after create — persist them with
|
||||||
|
// the status flip so ingress can route to the assigned port.
|
||||||
|
UpdateContainerPorts(ctx context.Context, arg UpdateContainerPortsParams) error
|
||||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ type NodeRunner interface {
|
|||||||
// RemoteRuntimeState reads the service's remote replicas from inventory
|
// RemoteRuntimeState reads the service's remote replicas from inventory
|
||||||
// across all agents. nil/empty means nothing runs remotely.
|
// across all agents. nil/empty means nothing runs remotely.
|
||||||
RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error)
|
RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error)
|
||||||
|
// RetireService removes every remote replica of the service across all
|
||||||
|
// agents — used when a service returns to local placement.
|
||||||
|
RetireService(ctx context.Context, serviceID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeploymentEngine struct {
|
type DeploymentEngine struct {
|
||||||
|
|||||||
@@ -182,6 +182,12 @@ func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSp
|
|||||||
}
|
}
|
||||||
return de.nodeRunner.ReconcileAuto(ctx, spec)
|
return de.nodeRunner.ReconcileAuto(ctx, spec)
|
||||||
}
|
}
|
||||||
|
// Local placement: retire any remote leftovers from a previous pin,
|
||||||
|
// spread, or tag affinity before reconciling local replicas.
|
||||||
|
if de.nodeRunner != nil {
|
||||||
|
_ = de.nodeRunner.RetireService(ctx, spec.ServiceID)
|
||||||
|
}
|
||||||
|
|
||||||
if spec.Replicas < 1 {
|
if spec.Replicas < 1 {
|
||||||
spec.Replicas = 1
|
spec.Replicas = 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ RETURNING *;
|
|||||||
UPDATE container_instances SET status = $2, updated_at = NOW()
|
UPDATE container_instances SET status = $2, updated_at = NOW()
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateContainerPorts :exec
|
||||||
|
-- Agent reports the real host bindings after create — persist them with
|
||||||
|
-- the status flip so ingress can route to the assigned port.
|
||||||
|
UPDATE container_instances SET status = $2, ports = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetLastAgentCommandByType :one
|
-- name: GetLastAgentCommandByType :one
|
||||||
SELECT * FROM agent_commands
|
SELECT * FROM agent_commands
|
||||||
WHERE node_agent_id = $1 AND type = $2
|
WHERE node_agent_id = $1 AND type = $2
|
||||||
@@ -174,7 +180,7 @@ LIMIT 1;
|
|||||||
UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1;
|
UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
-- name: ListServiceContainers :many
|
-- name: ListServiceContainers :many
|
||||||
SELECT id, name, node_agent_id, status FROM container_instances
|
SELECT id, name, node_agent_id, status, ports FROM container_instances
|
||||||
WHERE service_id = $1;
|
WHERE service_id = $1;
|
||||||
|
|
||||||
-- name: SetAgentSchedulable :exec
|
-- name: SetAgentSchedulable :exec
|
||||||
@@ -197,5 +203,9 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
-- name: DeleteServiceContainersOnAgent :exec
|
-- name: DeleteServiceContainersOnAgent :exec
|
||||||
DELETE FROM container_instances WHERE node_agent_id = $1 AND service_id = $2;
|
DELETE FROM container_instances WHERE node_agent_id = $1 AND service_id = $2;
|
||||||
|
|
||||||
|
-- name: ListServiceAgents :many
|
||||||
|
-- Distinct agents holding inventory rows for a service.
|
||||||
|
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1;
|
||||||
|
|
||||||
-- name: ScrubCommandPayload :exec
|
-- name: ScrubCommandPayload :exec
|
||||||
UPDATE agent_commands SET payload = $2, updated_at = NOW() WHERE id = $1;
|
UPDATE agent_commands SET payload = $2, updated_at = NOW() WHERE id = $1;
|
||||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/CLI.md",
|
"path": "guides/CLI.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Containr CLI",
|
"title": "Containr CLI",
|
||||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|anLine truncated
|
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|anLine truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
+6
-4
@@ -161,10 +161,12 @@ containr nodes update <id> --tags gpu,eu-west
|
|||||||
containr services update <id> --placement-tags gpu # only gpu nodes are eligible
|
containr services update <id> --placement-tags gpu # only gpu nodes are eligible
|
||||||
```
|
```
|
||||||
|
|
||||||
Remote nodes run registry-pulled images only — git builds, domains
|
Remote nodes run registry-pulled images only — git builds and sleep
|
||||||
(Traefik lives on the host), and sleep detection stay local. Start,
|
detection stay local. Domains work remotely: the agent publishes the
|
||||||
stop, restart, redeploy, and delete all fan out to the pinned node's
|
service port and the backend writes a Traefik file-provider route to the
|
||||||
agent. `services get` shows the resolved `node_name`.
|
node's mesh IP (TRAEFIK_DYNAMIC_DIR — wired by the infra compose).
|
||||||
|
Start, stop, restart, redeploy, and delete all fan out to the pinned
|
||||||
|
node's agent. `services get` shows the resolved `node_name`.
|
||||||
|
|
||||||
Take a node out of rotation or empty it entirely (admin):
|
Take a node out of rotation or empty it entirely (admin):
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ services:
|
|||||||
- "--api.insecure=${TRAEFIK_API_INSECURE:-true}"
|
- "--api.insecure=${TRAEFIK_API_INSECURE:-true}"
|
||||||
- "--providers.docker=true"
|
- "--providers.docker=true"
|
||||||
- "--providers.docker.exposedbydefault=false"
|
- "--providers.docker.exposedbydefault=false"
|
||||||
- "--providers.file.filename=/etc/traefik/traefik-dynamic.yml"
|
- "--providers.file.directory=/traefik-dynamic"
|
||||||
|
- "--providers.file.watch=true"
|
||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
- "--entrypoints.websecure.address=:443"
|
- "--entrypoints.websecure.address=:443"
|
||||||
- "--entrypoints.traefik.address=:8080"
|
- "--entrypoints.traefik.address=:8080"
|
||||||
@@ -26,7 +27,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ../data/letsencrypt:/letsencrypt
|
- ../data/letsencrypt:/letsencrypt
|
||||||
- ./traefik-dynamic.yml:/etc/traefik/traefik-dynamic.yml:ro
|
- traefik_dynamic:/traefik-dynamic
|
||||||
|
- ./traefik-dynamic.yml:/traefik-dynamic/00-base.yml:ro
|
||||||
networks:
|
networks:
|
||||||
- containr-network
|
- containr-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -100,6 +102,7 @@ services:
|
|||||||
- JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_key_change_in_production}
|
- JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_key_change_in_production}
|
||||||
- CONTAINR_AGENT_AUTH_TOKEN=${CONTAINR_AGENT_AUTH_TOKEN:-}
|
- CONTAINR_AGENT_AUTH_TOKEN=${CONTAINR_AGENT_AUTH_TOKEN:-}
|
||||||
- CONTAINR_AGENT_AUTH_TOKENS=${CONTAINR_AGENT_AUTH_TOKENS:-}
|
- CONTAINR_AGENT_AUTH_TOKENS=${CONTAINR_AGENT_AUTH_TOKENS:-}
|
||||||
|
- TRAEFIK_DYNAMIC_DIR=/traefik-dynamic
|
||||||
- COOKIE_SECURE=${COOKIE_SECURE:-true}
|
- COOKIE_SECURE=${COOKIE_SECURE:-true}
|
||||||
- TRUSTED_PROXY_CIDR=${TRUSTED_PROXY_CIDR:-172.20.0.0/16}
|
- TRUSTED_PROXY_CIDR=${TRUSTED_PROXY_CIDR:-172.20.0.0/16}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost,http://localhost:3000}
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost,http://localhost:3000}
|
||||||
@@ -136,6 +139,7 @@ services:
|
|||||||
- GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com}
|
- GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- traefik_dynamic:/traefik-dynamic
|
||||||
group_add:
|
group_add:
|
||||||
- "${DOCKER_GID:-0}"
|
- "${DOCKER_GID:-0}"
|
||||||
networks:
|
networks:
|
||||||
@@ -213,6 +217,8 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
dragonfly_data:
|
dragonfly_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
traefik_dynamic:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
containr-network:
|
containr-network:
|
||||||
|
|||||||
Reference in new issue
Block a user