{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.clarify.solutions/ship/v1/config.ship.schema.json",
  "title": "ship config.ship.yml",
  "description": "Deployment configuration for ship. The authoritative shape and validation live in Go under internal/core/config/; this schema is a hand-maintained editor aid. Service- and bootstrap-specific 'params' are intentionally left open (validated in Go by each plugin).",
  "type": "object",
  "additionalProperties": false,
  "required": ["ship_version", "ssh_user", "nodes", "services", "secrets"],
  "properties": {
    "name": {
      "type": "string",
      "description": "Deployment name. Defaults to the deployment directory's basename when omitted."
    },
    "ship_version": {
      "type": "string",
      "description": "The ship version this deployment targets."
    },
    "ssh_user": {
      "type": "string",
      "description": "SSH user ship connects to nodes as."
    },
    "timezone": {
      "type": "string",
      "description": "IANA timezone for the nodes (e.g. 'Europe/Oslo'). Defaults to Europe/Oslo."
    },
    "timesync": { "$ref": "#/$defs/timesync" },
    "secrets": { "$ref": "#/$defs/secrets" },
    "nodes": { "$ref": "#/$defs/nodes" },
    "bootstrap": {
      "type": "array",
      "description": "Explicit bootstrap actions. Base actions are added automatically when services are present; entries here are merged in.",
      "items": { "$ref": "#/$defs/bootstrapEntry" }
    },
    "preflight": { "$ref": "#/$defs/preflight" },
    "backups": { "$ref": "#/$defs/backups" },
    "services": { "$ref": "#/$defs/services" },
    "files": { "$ref": "#/$defs/files" },
    "domain": { "$ref": "#/$defs/nestedDomain" },
    "external_hosts": {
      "type": "array",
      "items": { "$ref": "#/$defs/externalHostEntry" }
    },
    "predownload_images": {
      "type": "boolean",
      "description": "Pre-pull images onto nodes before deploying. Default true."
    },
    "stream_local_images": {
      "type": "boolean",
      "description": "Stream images from the local Docker context instead of pulling on the node. Default false."
    },
    "local_docker_context": {
      "type": "string",
      "description": "Local Docker context used when streaming images. Default 'default'."
    },
    "registry_credentials": {
      "type": "array",
      "items": { "$ref": "#/$defs/registryCredential" }
    },
    "ipv4_only": {
      "type": "boolean",
      "description": "Restrict Docker to IPv4. Default true."
    },
    "ssh_connection_reuse": {
      "type": "boolean",
      "description": "Reuse SSH connections (ControlMaster) for ship's docker-over-SSH calls so repeated remote docker commands skip re-handshaking. Default true. macOS/Linux only."
    },
    "upgrade_notes": {
      "type": "array",
      "description": "Notes for the operator, shown before a deploy that changes the version of a service they name on a node (a fresh install counts). The deploy asks whether the note is done, even with --yes.",
      "items": { "$ref": "#/$defs/upgradeNote" }
    }
  },
  "$defs": {
    "secretKind": {
      "type": "string",
      "description": "Kind of secret material.",
      "enum": [
        "ssh",
        "token",
        "gcp-sa",
        "dockerconfig",
        "api-credential",
        "age-encryption-key",
        "ignition-root-key",
        "camera-credential",
        "basic-auth",
        "postgres-password",
        "ignition-licenses"
      ]
    },
    "serviceName": {
      "type": "string",
      "description": "A recognized service. See the Service* constants in internal/core/config/services.go.",
      "enum": [
        "acme-cert-sync",
        "coredns",
        "haproxy",
        "ignition",
        "watermoon-ignition",
        "node-red",
        "twin",
        "watermoon-scada",
        "watermoon-docs",
        "webbridge-keycloak",
        "watermoon-database",
        "video-manager"
      ]
    },
    "bootstrapAction": {
      "type": "string",
      "description": "A recognized bootstrap action. See the Action* constants in internal/core/config/bootstrap.go.",
      "enum": [
        "ensure-ansible-remote-tmp",
        "ensure-timezone",
        "setup-chrony",
        "install-docker",
        "initiate-swarm",
        "ensure-dns-setup",
        "ensure-docker-ipv4",
        "configure-needrestart",
        "configure-docker-shutdown-timeout",
        "ensure-qemu-guest-agent",
        "install-iperf3",
        "expand-root-lv",
        "configure-firewall"
      ]
    },
    "binarySize": {
      "type": "string",
      "description": "Binary size with a required unit (Ki, Mi, Gi, Ti), case-insensitive; one optional decimal place. E.g. '2Gi', '512Mi', '0.9Gi'.",
      "pattern": "^[0-9]+(\\.[0-9]+)?\\s*[KkMmGgTt][Ii]$"
    },
    "stringMap": {
      "type": "object",
      "additionalProperties": { "type": "string" }
    },
    "nodeParamRef": {
      "description": "Either a literal string, or a reference of the form { type: node, key: <param> }.",
      "oneOf": [
        { "type": "string" },
        {
          "type": "object",
          "additionalProperties": false,
          "required": ["type"],
          "properties": {
            "type": { "type": "string" },
            "key": { "type": "string" }
          }
        }
      ]
    },
    "nestedDomain": {
      "description": "A domain as a plain string, or as { root, sub } where sub is a string or list of strings.",
      "oneOf": [
        { "type": "string" },
        {
          "type": "object",
          "additionalProperties": false,
          "required": ["root"],
          "properties": {
            "root": { "type": "string" },
            "sub": {
              "oneOf": [
                { "type": "string" },
                { "type": "array", "items": { "type": "string" } }
              ]
            }
          }
        }
      ]
    },
    "timesync": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "private_servers": { "type": "array", "items": { "type": "string" } },
        "public_servers": { "type": "array", "items": { "type": "string" } }
      }
    },
    "secrets": {
      "type": "object",
      "description": "Secret sources. A onepassword adapter is required when services are present.",
      "additionalProperties": false,
      "properties": {
        "onepassword": {
          "type": "object",
          "additionalProperties": false,
          "required": ["account_name", "vault", "items"],
          "properties": {
            "account_name": { "type": "string" },
            "vault": { "type": "string" },
            "items": {
              "type": "array",
              "minItems": 1,
              "items": {
                "type": "object",
                "additionalProperties": false,
                "required": ["key", "item"],
                "properties": {
                  "key": { "type": "string" },
                  "item": { "type": "string" },
                  "kind": { "$ref": "#/$defs/secretKind" },
                  "vault": { "type": "string" },
                  "fields": { "$ref": "#/$defs/stringMap" }
                }
              }
            }
          }
        },
        "environment": { "$ref": "#/$defs/secretItemsAdapter" },
        "inline": { "$ref": "#/$defs/secretItemsAdapter" }
      }
    },
    "secretItemsAdapter": {
      "type": "object",
      "additionalProperties": false,
      "required": ["items"],
      "properties": {
        "items": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["key", "fields"],
            "properties": {
              "key": { "type": "string" },
              "kind": { "$ref": "#/$defs/secretKind" },
              "fields": { "$ref": "#/$defs/stringMap" }
            }
          }
        }
      }
    },
    "nodes": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name"],
        "description": "A node. Exactly one of 'ip' or 'hostname' must be set.",
        "properties": {
          "name": {
            "type": "string",
            "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$",
            "description": "A DNS label in lower case: letters, digits and hyphens, starting and ending with a letter or digit, at most 63 characters."
          },
          "ip": { "type": "string" },
          "hostname": { "type": "string" },
          "architecture": {
            "type": "string",
            "enum": ["linux/amd64", "linux/arm64"]
          },
          "params": {
            "type": "object",
            "description": "Per-node parameters (e.g. 'subdomain'); consumed by services.",
            "additionalProperties": true
          }
        },
        "oneOf": [
          { "required": ["ip"], "not": { "required": ["hostname"] } },
          { "required": ["hostname"], "not": { "required": ["ip"] } }
        ]
      }
    },
    "bootstrapEntry": {
      "type": "object",
      "additionalProperties": false,
      "required": ["action"],
      "properties": {
        "action": { "$ref": "#/$defs/bootstrapAction" },
        "params": {
          "type": "object",
          "description": "Action-specific parameters; validated in Go per action.",
          "additionalProperties": true
        }
      }
    },
    "preflight": {
      "type": "object",
      "additionalProperties": false,
      "description": "Host requirements. Checked on every deploy against the CPU and memory the ship-node agent reports; a node that falls short is refused. 'ship preflight' reports required vs. observed.",
      "properties": {
        "cpu_min": {
          "type": "integer",
          "minimum": 1,
          "description": "Minimum logical CPU count required on each node."
        },
        "memory_min": { "$ref": "#/$defs/binarySize" }
      }
    },
    "backups": {
      "type": "object",
      "additionalProperties": false,
      "description": "Scheduled backups: once a day, ship-node on every node backs up the services that run there and keeps the newest 'keep' of each. The block's presence turns the schedule on; 'backups: {}' uses the defaults. Deploy installs the schedule.",
      "properties": {
        "at": {
          "type": "string",
          "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
          "description": "Daily time the backups run, HH:MM in UTC. Default 02:30."
        },
        "keep": {
          "type": "integer",
          "minimum": 1,
          "description": "How many backups of each service a node keeps; the oldest beyond it are deleted after each successful backup. Default 10."
        },
        "gcs": { "$ref": "#/$defs/backupsGCS" }
      }
    },
    "backupsGCS": {
      "type": "object",
      "additionalProperties": false,
      "required": ["bucket", "folder", "credentials"],
      "description": "Upload every backup to a Cloud Storage bucket, as <folder>/<node>/<service>/<file>. Each upload replaces the previous copy of the file, so the bucket should keep versions. Unset keeps the backups on the nodes only.",
      "properties": {
        "bucket": {
          "type": "string",
          "pattern": "^[a-z0-9][a-z0-9_-]{1,61}[a-z0-9]$",
          "description": "The bucket's name, without gs://."
        },
        "folder": {
          "type": "string",
          "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$",
          "description": "The deployment's folder in the bucket, without leading or trailing slashes, such as eide/skotberget: by convention its path in the deployment repository, and the managed folder its key is granted on."
        },
        "credentials": {
          "type": "object",
          "additionalProperties": false,
          "required": ["key"],
          "properties": {
            "key": {
              "type": "string",
              "description": "Secret key referencing a 'gcp-sa' item: the service account key the nodes upload with. It needs storage.objects.create and storage.objects.delete on the folder; ship verify checks it does."
            }
          }
        }
      }
    },
    "services": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["service"],
        "properties": {
          "service": { "$ref": "#/$defs/serviceName" },
          "version": { "type": "string" },
          "image": {
            "type": "string",
            "description": "Full image ref override (e.g. ghcr.io/clarify/foo:1.2.3)."
          },
          "subdomain": { "$ref": "#/$defs/nodeParamRef" },
          "shared_subdomain": { "type": "string" },
          "domain": {
            "type": "string",
            "description": "Override the deployment domain for this service."
          },
          "resources": { "$ref": "#/$defs/serviceResources" },
          "stop_grace_period": {
            "type": "string",
            "description": "Go duration string, e.g. '2m' or '30s'."
          },
          "ready": { "$ref": "#/$defs/serviceReady" },
          "params": {
            "type": "object",
            "description": "Service-specific parameters; validated in Go by the service plugin.",
            "additionalProperties": true
          }
        }
      }
    },
    "upgradeNote": {
      "type": "object",
      "additionalProperties": false,
      "required": ["services", "note"],
      "properties": {
        "services": {
          "type": "array",
          "minItems": 1,
          "uniqueItems": true,
          "description": "Services whose version change shows the note. Each must be configured in services.",
          "items": { "$ref": "#/$defs/serviceName" }
        },
        "note": {
          "type": "string",
          "minLength": 1,
          "description": "The text shown to the operator, as written."
        }
      }
    },
    "serviceReady": {
      "type": "object",
      "additionalProperties": false,
      "description": "Tunes the readiness check the service contributes (the check itself is defined by the service).",
      "properties": {
        "enabled": {
          "type": "boolean",
          "description": "false turns the check off; it is still reported as off. Default true."
        },
        "timeout": {
          "type": "string",
          "description": "How long postflight waits for the check to pass, as a Go duration, e.g. '5m'. Default 3m."
        }
      }
    },
    "serviceResources": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "limits": { "$ref": "#/$defs/resourceSpec" },
        "reservations": { "$ref": "#/$defs/resourceSpec" }
      }
    },
    "resourceSpec": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "memory": {
          "type": "string",
          "description": "Memory, e.g. '2Gi', '4G', '512M'."
        },
        "cpus": {
          "type": "string",
          "description": "CPU share, e.g. '0.5', '2'."
        }
      }
    },
    "files": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["key", "source"],
        "properties": {
          "key": { "type": "string" },
          "source": {
            "type": "string",
            "enum": ["local"]
          },
          "path": { "type": "string" },
          "node": {
            "type": "string",
            "description": "Restrict the file to a single node (must match a node name)."
          }
        },
        "allOf": [
          {
            "if": {
              "properties": { "source": { "const": "local" } },
              "required": ["source"]
            },
            "then": { "required": ["path"] }
          }
        ]
      }
    },
    "externalHostEntry": {
      "type": "object",
      "additionalProperties": false,
      "required": ["name"],
      "description": "An external host. Exactly one of 'ip' or 'ips' must be set.",
      "properties": {
        "name": { "type": "string" },
        "ip": { "type": "string" },
        "ips": { "type": "array", "items": { "type": "string" } }
      },
      "oneOf": [
        { "required": ["ip"], "not": { "required": ["ips"] } },
        { "required": ["ips"], "not": { "required": ["ip"] } }
      ]
    },
    "registryCredential": {
      "type": "object",
      "additionalProperties": false,
      "required": ["registry", "key"],
      "properties": {
        "registry": { "type": "string" },
        "key": {
          "type": "string",
          "description": "Secret key referencing a 'token' or 'dockerconfig' secret item."
        },
        "username": { "type": "string" }
      }
    }
  }
}
