openapi: 3.0.3
info:
  title: Grain Daemon API
  description: |
    Control-plane HTTP API for the grain microVM daemon.
    Available over a Unix socket (local CLI) and optional TCP bind (`api` config).

    When `api_token` (or `auth_token`) is set in daemon config, all routes except
    `GET /healthz` require `Authorization: Bearer <token>`.
  version: 0.2.2
  license:
    name: Apache-2.0
    url: https://www.apache.org/licenses/LICENSE-2.0

servers:
  - url: http://127.0.0.1:7474
    description: Default TCP API bind
  - url: http://grain
    description: Unix socket (curl --unix-socket ~/.grain/grain.sock)

tags:
  - name: system
    description: Health, info, metrics, OpenAPI
  - name: vms
    description: VM lifecycle
  - name: agent
    description: Guest grain-agent proxy (exec, cp, fs)

security:
  - bearerAuth: []

paths:
  /healthz:
    get:
      tags: [system]
      summary: Liveness probe
      security: []
      operationId: healthz
      responses:
        "200":
          description: Daemon is up
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /info:
    get:
      tags: [system]
      summary: Daemon identity
      operationId: info
      responses:
        "200":
          description: Version and name
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:
                    type: string
                    example: grain
                  version:
                    type: string
                    example: 0.2.2
        "401":
          $ref: "#/components/responses/Unauthorized"

  /metrics:
    get:
      tags: [system]
      summary: Prometheus metrics
      operationId: metrics
      responses:
        "200":
          description: Prometheus text exposition format
          content:
            text/plain:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"

  /openapi.yaml:
    get:
      tags: [system]
      summary: OpenAPI 3.0 specification (YAML)
      operationId: openapiYAML
      responses:
        "200":
          description: OpenAPI document
          content:
            application/yaml:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"

  /openapi.json:
    get:
      tags: [system]
      summary: OpenAPI 3.0 specification (YAML body, JSON path alias)
      description: |
        Serves the same OpenAPI document as `/openapi.yaml` with
        `Content-Type: application/yaml`. Use `/openapi.yaml` when possible.
      operationId: openapiJSON
      responses:
        "200":
          description: OpenAPI document
          content:
            application/yaml:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"

  /vms:
    get:
      tags: [vms]
      summary: List VMs
      operationId: listVMs
      responses:
        "200":
          description: All known instances
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Instance"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [vms]
      summary: Create a VM
      description: |
        Blocking JSON create (default) waits until the VM is ready (SSH up).
        With `stream=1` (or `Accept: application/x-ndjson`), the response is an
        NDJSON stream of create progress events ending in `ready` or `error`.

        Query parameters:
        - `stream=1` — NDJSON progress (`application/x-ndjson`)
        - `wait` — readiness mode: `auto` (default when omitted; agent for golden
          images, else ssh), `ssh`, `agent`, or `userdata`. Legacy `true`/`1` → `ssh`.
        - `timeout` — optional Go duration override for the create deadline
          (e.g. `3m`, `90s`); defaults to daemon ReadyTimeout + buffer
      operationId: createVM
      parameters:
        - name: stream
          in: query
          description: "Set to `1` for NDJSON create progress stream"
          schema:
            type: string
            enum: ["1"]
        - name: wait
          in: query
          description: |
            Readiness mode. Empty/omitted uses daemon auto. Values: auto, ssh,
            agent, userdata. Legacy true/1 maps to ssh. false/0 is rejected.
          schema:
            type: string
            enum: [auto, ssh, agent, userdata, "true", "1"]
            example: agent
        - name: timeout
          in: query
          description: Create deadline as Go duration (e.g. 3m, 120s)
          schema:
            type: string
            example: 3m
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateRequest"
      responses:
        "201":
          description: VM created (non-stream)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Instance"
        "200":
          description: "NDJSON create stream (`stream=1`)"
          content:
            application/x-ndjson:
              schema:
                $ref: "#/components/schemas/CreateEvent"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /vms/{name}:
    parameters:
      - $ref: "#/components/parameters/VMName"
    get:
      tags: [vms]
      summary: Get VM by name
      operationId: getVM
      responses:
        "200":
          description: Instance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Instance"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
    delete:
      tags: [vms]
      summary: Delete VM
      operationId: deleteVM
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  name:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"

  /vms/{name}/start:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [vms]
      summary: Start a stopped persistent VM
      operationId: startVM
      responses:
        "200":
          description: Running instance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Instance"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /vms/{name}/shutdown:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [vms]
      summary: Shutdown VM
      description: Ephemeral VMs are removed; persistent VMs are left stopped.
      operationId: shutdownVM
      responses:
        "200":
          description: Shutdown acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  name:
                    type: string
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /vms/{name}/pause:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [vms]
      summary: Pause VM (QMP stop)
      operationId: pauseVM
      responses:
        "200":
          description: Paused
        "400":
          $ref: "#/components/responses/Error"

  /vms/{name}/resume:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [vms]
      summary: Resume paused VM (QMP cont)
      operationId: resumeVM
      responses:
        "200":
          description: Running
        "400":
          $ref: "#/components/responses/Error"

  /vms/{name}/suspend:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [vms]
      summary: Suspend persistent VM (stop process, free host RAM)
      description: |
        Unlike pause (QMP stop, process stays alive), suspend stops QEMU and frees host RAM.
        Persistent VMs only. When the root disk is qcow2, best-effort HMP savevm stores a
        snapshot tag grain-suspend for restore via -loadvm; otherwise restore cold-boots from disk.
      operationId: suspendVM
      responses:
        "200":
          description: Suspended
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  name:
                    type: string
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /vms/{name}/restore:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [vms]
      summary: Restore a suspended VM
      description: |
        Only allowed from status=suspended. Loads qcow2 internal snapshot when a suspend
        marker exists; otherwise starts from preserved disk like a cold boot.
      operationId: restoreVM
      responses:
        "200":
          description: Running instance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Instance"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /vms/{name}/forwards:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [vms]
      summary: Add live host→guest TCP forward
      description: |
        Starts an SSH local tunnel (ssh -N -L) on a running VM. host_port 0
        allocates a free high port. Returns the resolved LiveForward.
      operationId: addForward
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [guest_port]
              properties:
                host_port:
                  type: integer
                  description: 0 = allocate free host port
                guest_port:
                  type: integer
      responses:
        "201":
          description: Forward added
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LiveForward"
        "400":
          $ref: "#/components/responses/Error"

  /vms/{name}/forwards/{hostPort}:
    parameters:
      - $ref: "#/components/parameters/VMName"
      - name: hostPort
        in: path
        required: true
        schema:
          type: integer
    delete:
      tags: [vms]
      summary: Remove live host forward
      operationId: removeForward
      responses:
        "200":
          description: Removed
        "404":
          $ref: "#/components/responses/Error"

  /vms/{name}/stats:
    parameters:
      - $ref: "#/components/parameters/VMName"
    get:
      tags: [agent]
      summary: Guest resource stats via grain-agent
      operationId: vmStats
      responses:
        "200":
          description: Stats snapshot
        "503":
          $ref: "#/components/responses/Error"

  /secrets:
    get:
      tags: [system]
      summary: List host secrets metadata
      operationId: listSecrets
      responses:
        "200":
          description: Secret list
    post:
      tags: [system]
      summary: Create or replace a secret
      operationId: putSecret
      responses:
        "200":
          description: Stored

  /secrets/{name}:
    parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
    delete:
      tags: [system]
      summary: Delete a secret
      operationId: deleteSecret
      responses:
        "200":
          description: Deleted

  /vms/{name}/secrets/{secretName}:
    parameters:
      - $ref: "#/components/parameters/VMName"
      - name: secretName
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [agent]
      summary: Inject host secret into guest
      operationId: injectSecret
      responses:
        "200":
          description: Materialized in guest

  /vms/{name}/exec:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [agent]
      summary: Execute a command in the guest
      description: |
        Proxies to guest grain-agent. Non-zero remote exit codes still return HTTP 200
        with `exit_code` in the body (buffered mode).

        `buffered=false` streams NDJSON `ExecFrame` lines (`started` → stdout/stderr → `exit`).
      operationId: execVM
      parameters:
        - name: cmd
          in: query
          required: true
          schema:
            type: string
        - name: args
          in: query
          description: Repeatable command arguments
          schema:
            type: array
            items:
              type: string
          style: form
          explode: true
        - name: buffered
          in: query
          description: "`true` (default) returns JSON ExecResult; `false` streams NDJSON frames"
          schema:
            type: string
            enum: ["true", "false"]
            default: "true"
        - name: uid
          in: query
          schema:
            type: integer
        - name: gid
          in: query
          schema:
            type: integer
        - name: cwd
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Buffered result or NDJSON stream
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExecResult"
            application/x-ndjson:
              schema:
                $ref: "#/components/schemas/ExecFrame"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "502":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /vms/{name}/agent/health:
    parameters:
      - $ref: "#/components/parameters/VMName"
    get:
      tags: [agent]
      summary: Guest agent health
      operationId: agentHealth
      responses:
        "200":
          description: Agent health
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentHealth"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /vms/{name}/cp:
    parameters:
      - $ref: "#/components/parameters/VMName"
    put:
      tags: [agent]
      summary: Upload file or tar to guest
      operationId: putCP
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
        - name: mode
          in: query
          description: binary (default) or tar
          schema:
            type: string
            enum: [binary, tar]
            default: binary
        - name: permissions
          in: query
          description: File mode for binary uploads (e.g. 0644)
          schema:
            type: string
        - name: uid
          in: query
          schema:
            type: integer
        - name: gid
          in: query
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
          application/x-tar:
            schema:
              type: string
              format: binary
      responses:
        "204":
          description: Written
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "502":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"
    get:
      tags: [agent]
      summary: Download file or tar from guest
      operationId: getCP
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
        - name: mode
          in: query
          schema:
            type: string
            enum: [binary, tar]
            default: binary
      responses:
        "200":
          description: File or tar stream
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
            application/x-tar:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "502":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /vms/{name}/fs/readdir:
    parameters:
      - $ref: "#/components/parameters/VMName"
    get:
      tags: [agent]
      summary: List directory entries in guest
      operationId: fsReadDir
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Directory listing
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/FSInfo"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "502":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /vms/{name}/fs/stat:
    parameters:
      - $ref: "#/components/parameters/VMName"
    get:
      tags: [agent]
      summary: Stat a path in guest
      operationId: fsStat
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Path metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FSInfo"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "502":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /vms/{name}/fs/mkdir:
    parameters:
      - $ref: "#/components/parameters/VMName"
    post:
      tags: [agent]
      summary: Create directory in guest
      operationId: fsMkdir
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MkdirRequest"
      responses:
        "204":
          description: Created
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "502":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

  /vms/{name}/fs/remove:
    parameters:
      - $ref: "#/components/parameters/VMName"
    delete:
      tags: [agent]
      summary: Remove file or directory in guest
      operationId: fsRemove
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
        - name: recursive
          in: query
          schema:
            type: boolean
            default: false
      responses:
        "204":
          description: Removed
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/Error"
        "502":
          $ref: "#/components/responses/Error"
        "503":
          $ref: "#/components/responses/Error"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: token
      description: |
        Optional. Required when daemon `api_token` / `auth_token` is non-empty.
        CLI uses env `GRAIN_TOKEN` or the same config field.

  parameters:
    VMName:
      name: name
      in: path
      required: true
      schema:
        type: string

  responses:
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Error:
      description: Error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string

    CreateRequest:
      type: object
      properties:
        name:
          type: string
        persistent:
          type: boolean
          default: false
        cpus:
          type: integer
        memory_mb:
          type: integer
        disk_gb:
          type: integer
        image:
          type: string
        tags:
          type: object
          additionalProperties:
            type: string
        userdata:
          type: string
        forwards:
          type: array
          items:
            $ref: "#/components/schemas/PortForward"
        mounts:
          type: array
          items:
            $ref: "#/components/schemas/Mount"

    PortForward:
      type: object
      required: [guest_port]
      properties:
        host_port:
          type: integer
          description: 0 = allocate free host port
        guest_port:
          type: integer
        proto:
          type: string
          default: tcp

    LiveForward:
      type: object
      required: [host_port, guest_port]
      properties:
        host_port:
          type: integer
        guest_port:
          type: integer
        pid:
          type: integer
          description: ssh -N process id while the forward is active

    Mount:
      type: object
      required: [host, guest]
      properties:
        host:
          type: string
        guest:
          type: string
        tag:
          type: string

    Instance:
      type: object
      properties:
        name:
          type: string
        status:
          type: string
          enum: [creating, running, paused, suspended, stopped, error]
        suspended_at:
          type: string
          format: date-time
          description: Set when status is suspended
        persistent:
          type: boolean
        cpus:
          type: integer
        memory_mb:
          type: integer
        disk_gb:
          type: integer
        image:
          type: string
        ip:
          type: string
        ssh_port:
          type: integer
        agent_port:
          type: integer
        agent_cid:
          type: integer
          description: Guest virtio-vsock context ID (0 or omitted = TCP hostfwd only)
        forwards:
          type: array
          items:
            $ref: "#/components/schemas/PortForward"
        live_forwards:
          type: array
          description: Runtime SSH -L forwards (cleared on stop/delete)
          items:
            $ref: "#/components/schemas/LiveForward"
        mounts:
          type: array
          items:
            $ref: "#/components/schemas/Mount"
        tags:
          type: object
          additionalProperties:
            type: string
        created_at:
          type: string
          format: date-time
        error:
          type: string
        disk_path:
          type: string
        pid:
          type: integer

    CreateEvent:
      type: object
      properties:
        phase:
          type: string
          enum: [image, disk, seed, qemu, wait_ssh, wait_agent, ready, error]
        message:
          type: string
        name:
          type: string
        error:
          type: string
        ssh_port:
          type: integer
        instance:
          $ref: "#/components/schemas/Instance"

    AgentHealth:
      type: object
      properties:
        hostname:
          type: string
        agent_version:
          type: string
        agent_uptime_sec:
          type: integer
          format: int64
        userdata_ran:
          type: boolean

    ExecResult:
      type: object
      properties:
        stdout:
          type: string
        stderr:
          type: string
        exit_code:
          type: integer
        error:
          type: string

    ExecFrame:
      type: object
      properties:
        type:
          type: string
          enum: [started, stdout, stderr, exit, error]
        timestamp:
          type: string
        pid:
          type: integer
        data:
          type: string
        exit_code:
          type: integer
        error:
          type: string
        started_at:
          type: string
        ended_at:
          type: string

    FSInfo:
      type: object
      properties:
        name:
          type: string
        type:
          type: string
          enum: [file, directory, symlink]
        size:
          type: integer
          format: int64
        mtime:
          type: integer
          format: int64
          description: Unix seconds
        mode:
          type: string
          example: "0644"

    MkdirRequest:
      type: object
      required: [path]
      properties:
        path:
          type: string
        recursive:
          type: boolean
        mode:
          type: string
          example: "0755"
