Skip to content

📦 Package manager and registries ​

📌 Description ​

A package manager in the spirit of apt/homebrew: applications are described by an asc.yaml manifest, published in registries and installed with asc install <package>. Registries can be official or custom, local (file://) or remote (https://), including GitHub repositories.

🎯 Scenarios ​

  • asc install nginx — install from the official registry.
  • asc source add https://registry.example.com — connect a custom registry (like an apt source).
  • asc source add https://github.com/user/my-app — an application straight from GitHub (asc.yaml at the root); for a private repo a 404 triggers an offer to configure a token.
  • asc install https://github.com/user/my-app --branch dev — install directly from a repository URL, no registry involved at all (DMN-040): a one-off, for forks and packages that were never published. asc source add above wires a repo into the registry system permanently; this is for trying one out.
  • The platform store (🛍️ app-store) is a storefront over the same registries.

🏗️ Technical design ​

The asc.yaml manifest (draft) ​

yaml
name: my-app
version: 1.2.0
type: docker | native | utility
category: web                 # a registry topic (databases, ai, bots, game-servers…)
description: "..."            # EN in the official registry
settings: ./asc.settings.yaml # optional: the application settings file (see below)
runtime:
  image: nginx:1.27           # for docker
  stdin: true                 # docker, optional: keep stdin open (docker run -i) — `asc attach` input reaches the app
  tty: true                   # docker, optional: allocate a pseudo-TTY (docker run -t)
  # or install/start/stop commands for native
requirements: { ram: 256M, disk: 1G }
healthcheck: { http: /health }

ℹ️ The manifest has no env:, ports: or volumes: sections (DMN-027/030): environment variables, published ports and volumes are all declared in asc.settings.yaml — settings with an env: key and the ports / volumes setting types (see below). One source of truth: what the user can configure is exactly what the app gets.

ℹ️ Every type: docker container gets a raised open-files limit (ulimit nofile, 10240 soft/hard) instead of the Engine's own default of 1024 — several game servers (7 Days to Die's EOS SDK is the documented case) hang or crash during startup on that default with no clearer symptom than the process going silent after its first log line.

🏗️ Local image build: image-build (DMN-050) ​

A type: docker app can ship its own Dockerfile and have the Engine build the image locally instead of (or beside) pulling a prebuilt one:

yaml
runtime:
  image-build:
    context: .              # build context dir, relative to the manifest (default '.')
    dockerfile: Dockerfile  # relative to the context (default 'Dockerfile')
    args:                   # optional --build-arg values
      VERSION: "1.0"
    tag: asc-local/my-app   # optional; default 'asc-local/<app>:latest'
  • Only image → pull it, as before.
  • Only image-build → build the image from the package Dockerfile at install (and rebuild it on upgrade / a settings-drift recreate; layer caching keeps this cheap). The build context is packed from the package repository and never reaches outside it.
  • Both image and image-build → the installer offers a choice: interactively asc install <app> prints the two options and asks; non-interactively (or to skip the prompt) pass --image (pull the prebuilt one) or --build (build locally). The chosen source is recorded in meta.json so a later recreate or upgrade uses the same one without asking again.

⚠️ The build runs as the daemon (root). Until per-user container policy lands (DMN-043), building is intended for trusted packages; the base image referenced by FROM is pulled by the Engine anonymously (private base images for a local build are a later increment).

The build always goes through the Engine's BuildKit backend, not the legacy builder — Dockerfile syntax such as COPY --chmod/--chown needs it, and the legacy builder fails with "the --chmod option requires BuildKit" otherwise. On a terminal, each build step is rendered as a docker build-style progress bar per step (docker pull-style, mirroring layer bars for a pull), regardless of the log level.

📐 JSON schemas of the manifests: asc.schema.json, asc.stack.schema.json and asc.settings.schema.json in the registry repository.

Application settings: asc.settings.yaml ​

Application settings live in a separate file referenced from asc.yaml (settings: ./asc.settings.yaml). It describes the parameters: type, limits, enumerations, defaults — the user fills them in at install time (the platform UI renders a form, the CLI asks questions; silent mode takes the defaults):

yaml
settings:
  - key: server_name
    type: string                # string | number | boolean | enum | secret
    title: "Server name"
    default: "My Server"
    required: true
    env: SERVER_NAME            # exposed to the app as this env variable

  - key: max_players
    type: number
    default: 10
    limits: { min: 1, max: 200 }   # value limits

  - key: difficulty
    type: enum                  # an enumeration
    values: [peaceful, easy, normal, hard]
    default: normal

  - key: game_version
    type: enum                  # presets, but a custom value is also accepted
    values: [public, latest_experimental]
    default: public
    allow_custom: true           # any other branch/build id is accepted as-is

  - key: rcon_password
    type: secret                # stored as a secret, masked
    required: true

  - key: enable_backups
    type: boolean
    default: true

  - key: game_port
    type: ports                 # published container ports (a list)
    default: [27015]
    limits: { min: 1024, max: 65535 }
    env: CS2_PORT               # exposed comma-joined; one port — as is
    protocol: both               # tcp (default) | udp | both

  - key: game_data
    type: volumes               # app volumes (a list, forms below)
    default: [/home/steam/cs2-dedicated]
  • Setting values are saved in /asc/apps/<id>/config/settings.json (0600 — the file may hold secrets); at install time it is seeded with the defaults, an upgrade adds defaults for new keys without touching the user's choices.
  • Env pass-through: every setting that declares env: VAR_NAME lands in the application's environment (secrets included — that is what their env: is for). For Docker apps the variables go into the container env at creation. List values (ports, volumes) are exposed comma-joined.
  • type: ports — the published container ports (host port == container port). Declaring the same setting with env: keeps the app and Docker in sync: the server listens exactly where the port is forwarded. protocol picks the transport(s) to forward: tcp (default), udp, or both (the same host==container port on TCP and UDP).
  • type: volumes — the app's volumes; every entry takes one of three forms:
    • /container/path — private app data: the app's data folder (/asc/apps/<id>/data) is mounted at that container path. The folder is created world-writable (0777): images run under arbitrary non-root users and bind mounts keep host ownership; the app directory above it stays restrictive. When the image declares a numeric USER uid:gid, the folder is also chowned to it (DMN-038) — a non-root process may only chown a path it already owns, so an image that chowns its own data directory on first start (not just writes to it) needs this to avoid EPERM; a named (steam) or bare-uid USER is left as world-writable only, since its group is only known to the image's own /etc/passwd;
    • /container/path:host — same, but the host side after the colon is used instead of data: a plain folder name lands inside the app directory (/asc/apps/<id>/<folder>; repository, config and meta.json are reserved), an absolute path is a host machine path mounted verbatim (a pre-existing directory keeps its ownership and mode);
    • name:/container/path[:ro|:rw] — a Docker named volume, created by the Engine on first use. Named volumes are how several apps share data: one app writes the volume, others mount it :ro (see the cs2 stack in asc-example-apps). Named volumes are not removed with the app.
  • Applying changes: a container's configuration is fixed at creation, so on the next asc app start / asc app restart the daemon compares the desired state — env, published ports, volumes, quota, start command — with the container's actual one and recreates the container when they differ (or when the container is missing). App data lives in volumes and survives the recreate. If the desired state cannot be computed (say, the registry source is gone), the app still starts as is — availability wins, with a warning in the log.
  • Changing settings — asc app settings <id>: an interactive editor in the terminal. It first shows the categories — environments (string/number/boolean/enum/secret settings), ports, volumes, quota, start_command — then the settings of the picked category: pick one by number, enter a value, and it is validated against the definition (type, limits, enum values; secrets are masked in the list; ports and volumes take space-separated lists). Also via the platform UI. After a change the application is restarted (asc app restart <id>).
  • allow_custom (type: enum only) — accepts any value outside the declared values list as free text, instead of rejecting it. Use it for enums that list common presets but should not lock the user out of a value the author didn't anticipate (a game branch/build id, a custom world name). The numbered picker in asc app settings still lists the presets; typing anything else is simply accepted.
  • API access (DMN-078): GET/PUT /v1/apps/{id}/settings over the daemon's REST API, and the equivalent AppService.GetAppSettings/SetAppSettings over gRPC — the same values as the CLI editor, values_json/values as a JSON object rather than google.protobuf.Struct (which would turn an integer like a port into a double). A write returns restart_required: true when the app is currently running and its live configuration would now drift from the saved values — the caller should offer asc app restart <id> (or the platform's own restart action); a stopped app always answers false, since its values simply apply on the next start.

🔌 Environment group delivery ($env) ​

Alongside the reserved $quota/$start_command/$backup keys, settings.json accepts a fourth one: $env — an object of arbitrary NAME: value string pairs. It exists for the platform's Environment groups (org/project-scoped variable sets — 🌱 environments): a group connected to an app is resolved and merged by the platform, then written under this key the same way any other setting is, so it rides the same settings.json → apply-on-(re)start pipeline as everything above — no separate delivery channel.

json
{ "$env": { "BOT_TOKEN": "…", "TZ": "Europe/Moscow" } }
  • Names must match ^[A-Za-z_][A-Za-z0-9_]*$; a malformed entry is rejected when the values are saved, like any other setting.
  • Precedence: a package-declared setting with a matching env: key — and an actual value — always wins over a same-named $env entry. The user configured that value explicitly, in the app's own settings, which is a more specific and deliberate choice than an org/project-wide default. An $env entry with no matching package setting reaches the container env unconditionally.
  • Not shown by asc app settings's interactive editor (it is platform-managed) but preserved untouched by it, like any key the editor does not recognize.

📏 Resource quota (quota) ​

The quota: section of asc.settings.yaml limits the resources of one app instance (DMN-021):

yaml
quota:
  max_cpu: 2        # CPU cores limit (0.5, 2, …)
  max_ram: 1G       # memory limit: 512M, 2G, … (binary units, like docker -m)
  max_disk: 10G     # disk usage limit
  • The values are normalized at install/upgrade time and recorded in meta.json; asc app info <id> shows them (quota: cpu ≤ 2, ram ≤ 1.0 GiB, …).
  • Docker apps: enforced at container creation through the Engine API (NanoCpus, Memory).
  • native/process apps: recorded in meta.json; cgroup enforcement is a next increment.
  • max_disk is recorded for every runtime; per-runtime disk enforcement (Docker storage-opt / fs quotas) arrives incrementally.
  • User overrides (DMN-030): the quota category of asc app settings overrides individual fields on top of the package values ('-' resets a field back); the override lands in settings.json and applies through the container recreate on the next restart.

⚠️ Resource shortfall check and --force (DMN-099) ​

Before an install pulls or builds anything, the daemon compares the manifest's requirements: and the effective quota: against what the host actually has. A shortfall raises the typed pkg::resources::RequirementsNotMet error instead of running further — installing cs2 (requirements: { ram: 4G, disk: 80G, cpu: 2 }) on a 1-core/1GiB node used to fail deep inside docker create with a raw Engine error (Docker responded with status code 400: range of CPUs is from 0.01 to 1.00, as there are only 1 CPUs available); now it fails immediately with installing 'cs2' needs more than the host has right now: RAM 4.0 GiB > 0.9 GiB, disk 80.0 GiB > 14.0 GiB, CPU 2 > 1.

  • What is compared: RAM/disk against what is currently free on the host (other installed apps count against it, same rule asc app start's own advisory check already uses); CPU against the host's total logical core count, not its current load — cores are not consumed the way RAM is, and total capacity is what predicts the Engine's own validation. The CPU figure is the higher of requirements.cpu and the effective quota.max_cpu — the quota is what actually reaches NanoCpus, so its overshoot matters even when requirements.cpu is unset entirely (a quota:-only package, e.g. one with no requirements: section at all, still trips the check).
  • --force (asc install <pkg> --force, InstallAppRequest.force) skips the check and installs anyway. On the CLI, the interactive path prints what is short and asks "Install anyway at your own risk? [y/N]" instead of requiring the flag up front (non-interactive callers get the structured error and decide there).
  • The CPU quota is still capped to the host's total core count, --force or not. A quota above nproc is not a risk a human can accept away — the Engine hard-rejects it — so the effective NanoCpus sent to docker create is silently clamped down to the host's own core count whenever it would otherwise exceed it, with a warning line (asc install's live output, or the daemon's own log for a caller with no progress channel) naming the cap. RAM/disk are never clamped: nothing downstream enforces them against host capacity the way the Engine enforces CPU, so --force really does mean "install it your way."
  • API shape: mirrors LicenseRequired's license-consent shape field-for-field — InstallAppResponse.requirements_not_met (RequirementsNotMetDetail{ app, shortages: [{ resource, need, have }] }) instead of a gRPC error, so the platform's install dialog renders its own "not enough resources, install anyway?" screen and retries with force = true, the same round trip license consent already uses.

🚀 The start command (start_command) ​

The application's start command is configured in asc.settings.yaml. The string can interpolate the package's environment variables — ${VAR} syntax:

yaml
start_command: "steamcmd +force_install_dir /data +login anonymous +app_update ${STEAM_APP_ID} validate +quit"
  • The substitution is performed by the daemon at install/upgrade time from the app's env — the setting values with env: keys, defaults included. An unresolved variable fails the install naming the variable.
  • Docker apps: the command replaces what the image would run (the entrypoint becomes /bin/sh -c, so arguments and quoting work as in a shell).
  • native apps: the command overrides runtime.start from asc.yaml.
  • Interpolation resolves ${VAR} against the app's whole computed environment — the setting values with env: keys and any $env entries from a connected Environment group (see above) — so a start command can reference a group-delivered variable too, as long as it was written to settings.json before the (re)start that recomputes the command. A UI preview of the computed command on the platform is a next increment (🌱 environments).
  • User override (DMN-030): the start_command category of asc app settings replaces the package's command for this instance ('-' resets back); ${VAR} references resolve from the settings env when the override is applied.

🐳 install/update scripts: native or docker ​

A package's install and update scripts can run either natively on the host or in docker — controlled by the run_in field in the manifest's scripts: block:

yaml
scripts:
  install:
    run: ./scripts/install.sh
    run_in: native            # native | docker
  update:
    run: ./scripts/update.sh
    run_in: docker            # a one-off container
    image: debian:12          # the image for docker execution (optional)
  • native — the script runs on the host as the application's user.
  • docker — the script runs in a one-off container with the /asc/apps/<id>/ directory mounted (isolating build dependencies from the host).
  • By default run_in inherits the package type: docker → docker, native/utility → native.

Installation mechanics: cloning the repository ​

Installing an application = cloning its repository:

  1. asc install <package> → the daemon clones the package repository into /asc/apps/<id>/repository/.
  2. Application versions = git tags (GitHub tags), read from the repository, not the registry (DMN-047): installing a specific version — asc install <package>@1.2.0 (tag checkout); asc install <package> with no version resolves the repository's newest tag via git ls-remote (no tags → the default branch HEAD); asc install <package>@ (a bare @) lists the repository's tags and branches for an interactive pick (DMN-048), or, non-interactively, returns the available versions as an error. Updating — asc app upgrade <name> (checkout of the new tag). The registry index therefore carries no version field — a package's versions are whatever its repository is tagged with. API callers get the same tag list without any of that — AppService.ListAppVersions (DMN-0XX) takes a raw git URL and returns its tags (newest first) plus which one is latest, one git ls-remote round trip, no install/clone: what the platform's version picker (install wizard, upgrade dropdown) calls.
  3. License consent (DMN-028, DMN-032, DMN-091): when the cloned package ships a license (LICENSE.md / LICENSE / LICENSE.txt), the CLI shows where the package comes from (registry source + repository), prints the license text and asks for acceptance — declining aborts the install and leaves nothing behind. The license is looked up in the package's own directory first (a monorepo package may ship its own license), falling back to the repository root. Non-interactive input accepts automatically with a printed notice. API callers get the same fields (package, source, git, license) structurally rather than as prose, but not the same way on every transport: the local REST API (the CLI's own unix-socket transport) returns them as a license_required field on a 409 error response; over gRPC (AppService.InstallApp/InstallAppStream, what the platform uses) it is not an error at all — InstallAppResponse carries an optional license_required message instead of id/version, since the call otherwise succeeded and just needs one more round trip with license_ack = true. The platform UI renders its own consent dialog from those fields either way. A stack asks once per repository. Packages without a license file install without the prompt.
  4. Custom name and multiple instances (DMN-024, DMN-033): in a terminal asc install asks for an application name — Enter keeps the default, any other input becomes the app's name. Non-interactively, pass --name "My Server". Installing a package that is already installed no longer fails: it becomes a new instance with the next free id (<package>-2, <package>-3, …), which also becomes its custom_name unless --name overrides it. The name is stored in meta.json (custom_name), must be unique among the user's apps, survives upgrades, and every command accepts it interchangeably with the id. A suffixed instance records the registry package it came from (meta.package), so asc app upgrade keeps resolving it correctly.
  5. Names are case-insensitive, ids are lowercase (DMN-059): an app id becomes a directory, a container name and a systemd unit, so it is restricted to [a-z0-9_-] — but nothing about that is the user's problem. Wherever a name is derived (the repository name of a direct URL install, the registry entry name, the name: of asc.yaml / asc.stack.yaml) it is folded into that shape: HOMEBAR installs as homebar, Home.Bar as home-bar. Wherever a name is typed it is matched case-insensitively — asc install HOMEBAR, asc install mystack/MyApp, asc app start HOMEBAR and asc app logs homebar all reach the same app. A repository that only survives an install if its author renames it is a bug, not a rule.
  6. From then on the daemon works with the local copy: reads asc.yaml/asc.settings.yaml, builds/launches according to the application type.
  7. For Docker applications the container is created through the Engine API; an image missing on the host is pulled automatically from its registry (runtime.image; a name without a tag means latest) — both at install and at upgrade.
  8. Progress bars: on a terminal, the repository clone (git clone --progress) and a Docker image pull both render as live bars (docker pull/docker-compose pull style — one bar per image layer) — on by default, independent of asc config debug. Non-interactive callers (piped output, the daemon API) get none of this; the same events are always available as debug-level tracing (asc config debug on).

Reading a package without cloning it (DMN-131): AppService.InspectPackage — what the platform's install dialog calls before installing anything — never downloads the package content. It takes a sparse, blobless snapshot: git clone --depth 1 --filter=blob:none --no-checkout fetches the tip commit and its trees only (a file listing, no contents), core.sparseCheckout limits the working tree to the files a package is read from — asc.yaml, asc.stack.yaml, asc.settings.yaml (plus a custom settings: path, fetched on demand), LICENSE* of the package directory and the repository root, and what the install-method detector looks at (compose files, Dockerfile*, Chart.yaml, YAML up to three levels deep) — and git read-tree -mu HEAD fetches just those blobs in one batch. A repository with gigabytes of assets costs a few kilobytes per inspect. core.sparseCheckout with .git/info/sparse-checkout is used rather than git sparse-checkout set --no-cone, which needs git 2.35 — older than what Debian 11 / Ubuntu 20.04 ship. A server without partial-clone support ignores the filter and sends the blobs anyway: the snapshot degrades to the old shallow clone, it never fails because of it. Private repositories go through the same credentials (asc auth), including for the lazy blob fetch. The same read answers the two questions an install used to discover only by failing a first run: InspectPackageResponse.license carries the license text the install will ask consent for (the same fields as license_required), and requirements_not_met the resource shortfall on this host right now (the check InstallApp runs without force, run ahead of it — the first non-optional stack member that does not fit). The installer asks both up front and starts the one real install with license_ack/force already decided. A direct git install uses the same snapshot to tell an app from a stack before its one full clone, instead of cloning as an app first and restarting as a stack.

Requirements at start (DMN-029): the manifest's requirements (ram, disk, cpu) are compared with what the host has free when the app starts. When short, asc app start warns with the exact figures and — in a terminal — asks whether to start anyway at the user's own risk; non-interactive callers get the warning on stderr and proceed. The check is advice, not enforcement: read failures never block the start.

Updating (asc app upgrade <name>[@version], synonym — asc upgrade): the application must be stopped; the new tag is cloned next to the current copy (repository.new), the manifest is validated, and only then are the directories swapped and the runtime recreated (for Docker the container is recreated with the new image). A failure before the swap does not touch the installed application; a failure while recreating the runtime rolls back to the previous version. Without an explicit version, the repository's newest tag is used (DMN-047); a repository with no tags cannot be upgraded without an explicit @version. Already being on the requested (or newest) version is reported as such, not as a fake upgrade. Over the API (DMN-0XX/NODE-022), AppService.UpgradeApp mirrors InstallApp — spec is the app id, optionally @version — and UpgradeAppStream mirrors InstallAppStream exactly (a line event per step, ending in the same result UpgradeApp would have returned): the daemon still requires the app to be stopped first, so the platform's facade stops it, streams the upgrade, and restarts it if it was running — the daemon itself does not orchestrate that.

Direct install from a git repository (asc install <url> [--branch <name>|--tag <name>] [--path <subdir>], DMN-040): when the spec is a git URL (https://, ssh://, or the scp-like git@host:path) rather than a package name, the daemon skips the registry entirely and clones the repository straight in — the same clone/manifest/provision pipeline as a registry install, minus the resolution step. asc.yaml is looked up at the repository root by default; --path <subdir> (DMN-096) says where to find the manifest inside the clone instead — the same idea as a registry entry's source.path, just with no entry to resolve it for the caller. --branch/--tag pick the ref to check out; neither flag clones the default branch HEAD. The app id defaults, with --path set, to the path's own last segment folded to a canonical lowercase id (asc install https://github.com/AdminServiceCloud/asc-example-apps --path web/helloworld installs as helloworld, not asc-example-apps), otherwise the repository's own name (bar for both https://github.com/foo/bar.git and git@github.com:foo/bar, homebar for https://github.com/mireblood/HOMEBAR, see point 5 above); --name overrides the id either way. The path is recorded in meta.json (repo_path) and survives asc app upgrade — without it, upgrading a monorepo package would break the same way installing one without --path used to. Private repositories reuse the exact same asc auth credentials below — a URL install is just a registry install with the resolution step removed. asc upgrade re-resolves apps installed this way from the recorded URL (DMN-053): a tag install moves to the repository's newest tag, a --branch install follows its branch. Re-running asc install with the same URL still creates a new instance rather than upgrading in place — that is what a second instance is for.

The same field is available to API callers: the platform installs custom-registry packages exactly this way — the package's bare git URL, not its name through the daemon's own registry (so the install does not depend on whether the organization's registry happens to be configured on that particular node) — and before DMN-096 installing any monorepo package (several apps in one repository) from the platform failed outright: path was dropped at the InstallAppRequest boundary, so the daemon looked for asc.yaml at the clone root and named the app after the repository. InstallAppRequest.path (both unary InstallApp and streamed InstallAppStream) is that same field — the CLI flag's wire equivalent, carried as its own argument rather than folded into the spec string.

Credentials (DMN-045/046): one per-user store holds authorization both for private repositories (git clone) and for private image registries (the Engine pull), told apart by a type field (repo | registry). Entries are keyed per host or prefix (github.com/myorg, ghcr.io/myorg) and stored separately from the source lists, as JSON: /etc/asc/auth.json (root) and ~/.asc/auth.json (user, alongside the rest of the per-user DMN-041 tree), both files 0600. Secrets end up neither in world-readable files nor in the argv of git processes (argv is visible to all users via /proc).

json
{
  "credentials": [
    { "type": "repo",     "pattern": "github.com/myorg", "token": "ghp_xxx" },
    { "type": "registry", "pattern": "ghcr.io/myorg", "username": "me", "token": "ghp_yyy" },
    { "type": "repo",     "pattern": "github.com/myorg/secret-app", "app": "6f8a…uuid", "key": "/home/me/.ssh/id_ed25519" }
  ]
}
  • Types: repo authorizes a git clone — a token for https:// URLs (via GIT_ASKPASS + an environment variable of the git process) or an SSH key for git@/ssh:// URLs (GIT_SSH_COMMAND with -i <key> -o IdentitiesOnly=yes). The URL follows the credential (DMN-059): a credential only authorizes the transport it belongs to, so cloning https://github.com/org/app with an SSH key configured for that host goes over ssh instead (git@github.com:org/app.git), and an ssh URL with a token configured goes over https. Without that rewrite the configured credential would simply be ignored and the clone would fail as if nothing had been set up. registry authorizes an image pull — a token plus username, sent to the Docker Engine as the X-Registry-Auth header (the Engine, not the daemon, then contacts the registry, so no TLS stack is needed daemon-side). A registry entry rejects an SSH key and requires --username.
  • App binding (app, DMN-044): a credential may be scoped to a single application by its uuid or id (--app), so a token serves exactly the app that needs it and no other. An app-bound entry is invisible to every other app and beats an equally specific unbound one for its own app; leaving app unset applies the credential to every app whose URL/image matches the pattern. Matching otherwise follows the same rule for both types: the longest prefix at a path boundary wins, and user entries take priority over system ones. Patterns match case-insensitively (DMN-059) — host names are case-insensitive by definition and the forges treat owner/repository names the same way, so a key added for github.com/MyOrg authorizes a clone of github.com/myorg/app and the other way round.
  • Detection: git always runs with GIT_TERMINAL_PROMPT=0 and BatchMode=yes — cloning a private repository without configured authorization does not hang on a password prompt but fails with a recognizable error (including the "Repository not found" that GitHub returns for private repositories without access). A private image pull surfaces the Engine's own authorization error. An ssh clone against a host missing from the known_hosts of the user running asc (root, for the daemon) fails the same way — BatchMode has no "continue connecting?" prompt to offer — so that error carries the ssh-keyscan <host> >> ~/.ssh/known_hosts fix with it (DMN-059).
  • Interactive setup: on detecting a private repository, the CLI in a terminal asks permission to configure authorization right away: for ssh — a pick from the private keys found in ~/.ssh, for https — a token, or that same key picker when the user has keys and chooses it (the clone then switches to the ssh transport, see Types); the choice is saved and the installation retries automatically. Under sudo the keys offered are the invoking user's, taken from their home in the user database rather than from $HOME (which distributions rewrite inconsistently). A non-interactive call (API, scripts) gets a structured error with an asc auth add ... hint.
  • Interactive setup through the daemon (DMN-062): the prompt is not limited to the in-process path. When the install runs through the daemon — which is the normal case, asc install reaches for the socket first — a private repository comes back as a structured auth_required (HTTP 409 with {"auth_required": {"url": …}}, deliberately not 401, which on the TCP listener belongs to the API bearer token), the CLI reconstructs the same typed error the in-process clone raises, runs the same prompt, and retries. So asc install https://github.com/<user>/<private-repo> offers the key/token choice, writes it to asc auth, and installs — in one command, without a separate asc auth add round.
  • Whose credentials the daemon uses (DMN-062): the daemon runs as root, so on its own it would only ever see /etc/asc/auth.json — and a regular user cannot write that file, which would make the prompt above pointless without sudo. Instead the daemon resolves credentials on behalf of the caller: the peer uid (SO_PEERCRED, or the attributed SUDO_UID for a root peer) yields that user's home from the user database, and their ~/.asc/auth.json is read as a higher-priority list on top of the system store. An SSH-key entry coming from a user store must point at a key owned by that user — the daemon would otherwise open it as root, and a hand-edited ~/.asc/auth.json pointing at /root/.ssh/id_ed25519 must not turn into a way to clone with root's key; foreign and unreadable keys are ignored with a warning in the journal. Registry credentials for image pulls still come from the system store alone.
  • Migration: the pre-DMN-045 TOML files (/etc/asc/git-auth.toml, ~/.config/asc/git-auth.toml) are still read when no JSON store exists — their entries have no type and load as repo — and are migrated to auth.json on the next asc auth write, so configured auth keeps working across the upgrade.
  • CLI: asc auth add <host|prefix> [--type repo|registry] --token <token> [--username <user>] [--app <uuid|id>] · asc auth add <host> --ssh-key [path] (without a path — an interactive key picker) · asc auth list (types and methods without secrets) · asc auth remove <host> [--type repo|registry].
  • CredentialService: the same store, managed by API instead of the CLI — this is how the platform pushes an organization's registry credentials onto a node without ever running asc auth add over SSH. ListCredentials/UpsertCredential/RemoveCredential operate on the system store only (/etc/asc/auth.json) — a user's own ~/.asc/auth.json stays exclusively under the CLI. UpsertCredential upserts by (kind, pattern, app), same as GitAuth::add above, and now takes either secret shape: a token, or raw PEM private-key bytes. For a key, the daemon writes it to a 0600 file it owns under /etc/asc/ssh-keys and registers a Method::SshKey pointing at it — the same shape asc auth add --ssh-key produces by hand. Replacing or removing that credential deletes the file too. ListCredentials never returns the secret itself, key file included. Ownership marker (managed_by): UpsertCredentialRequest.managed_by lets a caller stamp its own entries (the platform uses "platform"), and RemoveCredentialRequest.managed_by, when set, narrows a removal to entries carrying that exact marker — a platform push can list its own credentials, diff them against what it just sent, and remove the leftovers without ever touching an entry the operator added by hand through asc auth add. asc auth list prints managed_by=<value> next to any entry that carries one.

Several applications in one repository: asc.stack.yaml ​

The root rule: a package repository may contain any number of asc.yaml files in subdirectories, but its root must hold exactly one manifest — either asc.yaml (a single application) or asc.stack.yaml (a stack) that ties all the nested asc.yaml files together. Nested manifests without a root one are not indexed.

The asc.stack.yaml stack manifest lists the applications and the paths to their asc.yaml files:

yaml
name: my-stack
version: 1.0.0
description: "..."
apps:
  - name: web
    path: ./web            # the directory with asc.yaml
  - name: worker
    path: ./worker
  - name: db
    path: ./db
    optional: true          # an optional stack component
  • asc install my-stack — install the whole stack; asc install my-stack/web — just one application from it.
  • Renaming a stack at install (DMN-034): asc install my-stack --name prod is a prefix applied to every installed app — prod-web, prod-worker, prod-db — since a stack has no entity of its own, only its member apps. asc install my-stack/web --name prod-web names just the requested app, as for a single-app install.
  • A stack can declare dependencies between applications (depends_on — the startup order); components can be optional. Environment variables live in each app's own asc.settings.yaml.
  • Registries and the platform store index both single asc.yaml files and asc.stack.yaml stacks.
  • Examples — in the asc-example-apps repository.

Stack install mechanics: the repository is cloned once — to read asc.stack.yaml and as the source of every member (DMN-131): each selected application installs like a regular app, with its own copy of that clone under /asc/apps/<id>/repository/, its own directory and meta.json, but without fetching the repository again:

  • The app id is the name from the app's own asc.yaml (in the example above the stack's web app may be named my-stack-web); the origin is recorded in meta.json as package: "my-stack/web" — asc app upgrade resolves the package through it.
  • Order: dependencies (depends_on) install first; cycles are rejected at validation. asc install my-stack installs every non-optional component; asc install my-stack/db installs the requested component (even an optional one) plus its dependencies.
  • Repeat installs: a wanted app (the one(s) requested, not a dependency pulled in alongside them) that is already installed becomes a new instance (DMN-033) instead of being skipped; a dependency that is already installed is still reused, not duplicated. Every app installs atomically (a failure removes only its own directory, previously installed components stay).
  • License consent is asked once per repository (one repo = one license), not per stack app.

A stack installed straight from a git URL (DMN-097): a registry entry declares its type (app or stack), a bare URL declares nothing — so asc install <url> [--path <subdir>] finds out from the clone. When the package directory holds asc.stack.yaml instead of asc.yaml, the install restarts as a stack install and puts every non-optional app on the machine, exactly as asc install my-stack would; --app <name> installs one app of the stack instead (the direct-install equivalent of the registry's <stack>/<app> spec — a URL cannot carry that slash), and --name is the same prefix it is for a registry stack. Each app records the repository in meta.source and its own manifest subdirectory in meta.repo_path (gameservers/cs2/server, not the stack root), so asc app upgrade afterwards reads the app manifest and not asc.stack.yaml. The stack itself is recorded too (DMN-121): meta.package = "<stack>/<app>", the stack named like the install itself — after the manifest subdirectory, or the repository when the stack sits at its root — so asc stacks and the platform group a git-installed stack the same way as one from a registry; upgrades still go by source and repo_path. A registry entry that mislabels a stack as type: app now says so (package 'cs2' is a stack of 2 app(s) (server, master), not a single application) instead of failing with cannot read manifest .../asc.yaml: No such file or directory.

⚠️ This is what the platform's install dialog relies on: it installs every registry package by its git URL + path rather than by registry spec, so before DMN-097 a type: stack package (e.g. cs2) could not be installed from the UI at all.

Reading a package without installing it (InspectPackage, DMN-098): what a repository actually ships — one app (asc.yaml) or a stack (asc.stack.yaml) with its apps, their install ids, depends_on, optional flags and declared requirements. The daemon clones the repository shallowly into a temporary directory, reads the manifests and deletes the clone: nothing is installed and no app directory is created. An installer UI calls it before the install so the operator sees that a package is a stack and which apps it is about to put on the node — the registry index carries the package type but never the stack's contents, and a direct URL install has no registry entry at all. gRPC only (AppService.InspectPackage); the CLI shows the same thing by installing or by reading the repository.

Detecting every installation method a repository supports (DMN-106): a repository with no asc.yaml/asc.stack.yaml used to fail InspectPackage outright. It now reports back PACKAGE_KIND_UNSPECIFIED plus every install method detected in the package directory — ASC's own manifest/stack, a docker compose file, a bare Dockerfile, a Swarm stack, Kubernetes manifests, or a Helm chart — each with the files that triggered it, whether it's installable today, and a plain-English reason when it isn't. A compose file with a deploy: block reports both DOCKER_COMPOSE and SWARM, since it's one file usable two ways. The filesystem walk this needs is bounded (depth 3, ~5,000 entries, skipping .git/node_modules/vendor/target/dist), and compose itself is parsed permissively rather than with the strict, unknown-field-rejecting parser asc.yaml uses — compose is a large foreign format, and a feature this daemon doesn't model must not turn "detected" into "failed to parse". The same change also fixes a private-repository clone surfacing as a bare internal error over gRPC: InspectPackageResponse.auth_required/InstallAppResponse.auth_required now carry the URL, its normalized pattern, and which transport (https/ssh) authorizes it, the same non-error shape license_required/requirements_not_met already use.

Installing straight from a bare Dockerfile (DMN-107): a repository with a Dockerfile and nothing else installs as a normal type: docker app — asc install <url> --method dockerfile synthesizes a manifest and settings in memory (EXPOSE → published ports, VOLUME → volumes, paired host/container exactly like a hand-written asc.settings.yaml) instead of reading them from the repository, and everything downstream of that (license/resource checks, provisioning) is unchanged. Nothing synthesized is ever written to the repository, so every later reader — a settings-drift recreate, asc app upgrade, disk usage, ports, asc app clone — re-synthesizes the same manifest from the install method recorded in the app's own metadata rather than failing outright on a missing asc.yaml.

Installing from a bare docker-compose.yml (DMN-108/DMN-109): a repository with a compose file and nothing else installs as a docker compose project — no settings, no quota, no requirements check, since there is no manifest at all here. This one exception to "Docker only through bollard" is deliberate and documented: reimplementing compose's interpolation, env_file, extends, profiles, override files and healthcheck-aware depends_on over the Engine API would be a project of its own, so the docker compose CLI plugin (v2) is shelled out to instead, pointed at the same configured socket bollard uses everywhere else. The plugin is optional and probed once; missing it leaves compose a "found, not installable" method like Swarm/Kubernetes. Installing provisions with docker compose create (containers exist, not started, the same contract as a normal Docker app); stop is stop, never down, so the app's containers and volumes survive it; ports are read from the compose file so a stopped app still reports them; exec/attach are refused (no single container to address) while logs work, already multiplexed by service. The single most load-bearing check is refusing any service that bind-mounts a host path outside the cloned repository, before a container is ever created from it.

Registries ​

  • Registry format (the registry repo) — a hierarchy of JSON files: the root index registry.json → category files categories/<topic>.json (databases, ai, bots, game-servers, system-utilities, web…) → optional subcategories (children). Packages come in two kinds: app (asc.yaml) and stack (asc.stack.yaml). Validation schemas — in registry/schema/. Descriptions are in English.
  • Source tree: the daemon's sourcelist → a tree is built from all registries (following the root index's index/children links), then a merged application list (per user); in search results name conflicts are resolved by source priority.
  • Name conflicts at install: when several sources provide the requested package, asc install in a terminal lists the candidates (source name + repository) and asks which one to use (pick a number); non-interactive callers get an error with the same list — pin the registry explicitly: asc install <pkg> --source <name> (API: the source field of InstallAppRequest). asc app upgrade prefers the source the app was installed from.
  • Source types: file:// (a local directory) and https:// (a registry, GitHub raw).
  • Fetch resilience (DMN-036): each registry index file (registry.json, category files) is a separate curl invocation with no connection reuse between them — a whole asc update/asc search run is a short burst of several small HTTPS requests to the same host. A stalled connection on any one of them (CDN throttling, transient network hiccup) used to hang the whole command for up to 5 minutes with zero output. Registry fetches now use a short per-request timeout (20s) plus a couple of retries on transient errors, instead of the generous 300s budget reserved for large downloads (asc-updater release assets).
  • asc update progress (DMN-037): on a terminal, every index file gets its own progress line, docker pull/git clone style — a spinner while the request is in flight, frozen on its byte count on success or the error on failure. Since the registry is fetched one small file at a time, this is what turns a stuck fetch into a visibly stuck spinner instead of a silent wait.
  • Per-user source lists: two list levels —
    • system /etc/asc/sources.toml — managed by root (sudo asc source add|remove), the sources are visible to all users of the server;
    • user ~/.config/asc/sources.toml — each user maintains their own list (asc source add|remove without sudo), which extends the system one.
    • The effective list = system sources (higher priority) + your own; a user cannot shadow or remove system sources (asc source list shows the origin of every source). Index caches: for root — in data_dir, for a user — in ~/.cache/asc/.
  • Install policy ([policy] in /etc/asc/config.toml, managed by root): user_install = "all" (default — users may install any packages: Docker, native, utilities) or user_install = "docker" (users may install Docker applications only; native apps and utilities are root-only). Applied at asc install; does not apply to root.
  • Index cache with a TTL + asc update for a forced refresh.
  • CLI: asc install|remove|upgrade <pkg> (or asc install <git-url> [--branch|--tag] [--path <subdir>] [--app <stack app>] [--force]), asc search <query>, asc source add|remove|list, asc update.

DMN-003, DMN-018, DMN-038, DMN-040, DMN-045, DMN-046, DMN-047, DMN-048, DMN-059, DMN-084, DMN-096, DMN-097, DMN-098, DMN-099, REG-001, REG-002, REG-005, BE-002, BE-003, BE-028, BE-029, BE-030, NODE-031, NODE-032, NODE-033, FE-090, FE-091 in ROADMAP.md; GRW-011 in ROADMAP-GROWTH.md.

Released under the MIT License.