📦 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 addabove 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)
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:orvolumes:sections (DMN-027/030): environment variables, published ports and volumes are all declared inasc.settings.yaml— settings with anenv:key and theports/volumessetting types (see below). One source of truth: what the user can configure is exactly what the app gets.
ℹ️ Every
type: dockercontainer 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:
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
imageandimage-build→ the installer offers a choice: interactivelyasc 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 inmeta.jsonso 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
FROMis 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
registryrepository.
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):
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_NAMElands in the application's environment (secrets included — that is what theirenv: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 withenv:keeps the app and Docker in sync: the server listens exactly where the port is forwarded.protocolpicks the transport(s) to forward:tcp(default),udp, orboth(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 numericUSER 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 thatchowns its own data directory on first start (not just writes to it) needs this to avoid EPERM; a named (steam) or bare-uidUSERis 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 ofdata: a plain folder name lands inside the app directory (/asc/apps/<id>/<folder>;repository,configandmeta.jsonare 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 restartthe 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, enumvalues; 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 declaredvalueslist 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 inasc app settingsstill lists the presets; typing anything else is simply accepted.- API access (DMN-078):
GET/PUT /v1/apps/{id}/settingsover the daemon's REST API, and the equivalentAppService.GetAppSettings/SetAppSettingsover gRPC — the same values as the CLI editor,values_json/valuesas a JSON object rather thangoogle.protobuf.Struct(which would turn an integer like a port into a double). A write returnsrestart_required: truewhen the app is currently running and its live configuration would now drift from the saved values — the caller should offerasc app restart <id>(or the platform's own restart action); a stopped app always answersfalse, 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.
{ "$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$enventry. 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$enventry 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):
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_diskis recorded for every runtime; per-runtime disk enforcement (Docker storage-opt / fs quotas) arrives incrementally.- User overrides (DMN-030): the
quotacategory ofasc app settingsoverrides individual fields on top of the package values ('-'resets a field back); the override lands insettings.jsonand 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 ofrequirements.cpuand the effectivequota.max_cpu— the quota is what actually reachesNanoCpus, so its overshoot matters even whenrequirements.cpuis unset entirely (aquota:-only package, e.g. one with norequirements: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,
--forceor not. A quota abovenprocis not a risk a human can accept away — the Engine hard-rejects it — so the effectiveNanoCpussent todocker createis 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--forcereally 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 withforce = 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:
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.startfromasc.yaml. - Interpolation resolves
${VAR}against the app's whole computed environment — the setting values withenv:keys and any$enventries from a connected Environment group (see above) — so a start command can reference a group-delivered variable too, as long as it was written tosettings.jsonbefore 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_commandcategory ofasc app settingsreplaces 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:
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_ininherits the packagetype:docker→ docker,native/utility→ native.
Installation mechanics: cloning the repository
Installing an application = cloning its repository:
asc install <package>→ the daemon clones the package repository into/asc/apps/<id>/repository/.- 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 viagit 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 islatest, onegit ls-remoteround trip, no install/clone: what the platform's version picker (install wizard, upgrade dropdown) calls. - 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 alicense_requiredfield on a409error response; over gRPC (AppService.InstallApp/InstallAppStream, what the platform uses) it is not an error at all —InstallAppResponsecarries an optionallicense_requiredmessage instead ofid/version, since the call otherwise succeeded and just needs one more round trip withlicense_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. - Custom name and multiple instances (DMN-024, DMN-033): in a terminal
asc installasks 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 itscustom_nameunless--nameoverrides it. The name is stored inmeta.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), soasc app upgradekeeps resolving it correctly. - 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, thename:ofasc.yaml/asc.stack.yaml) it is folded into that shape:HOMEBARinstalls ashomebar,Home.Barashome-bar. Wherever a name is typed it is matched case-insensitively —asc install HOMEBAR,asc install mystack/MyApp,asc app start HOMEBARandasc app logs homebarall reach the same app. A repository that only survives an install if its author renames it is a bug, not a rule. - From then on the daemon works with the local copy: reads
asc.yaml/asc.settings.yaml, builds/launches according to the application type. - 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 meanslatest) — both at install and at upgrade. - 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 pullstyle — one bar per image layer) — on by default, independent ofasc config debug. Non-interactive callers (piped output, the daemon API) get none of this; the same events are always available asdebug-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).
{
"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:
repoauthorizes a git clone — a token forhttps://URLs (viaGIT_ASKPASS+ an environment variable of the git process) or an SSH key forgit@/ssh://URLs (GIT_SSH_COMMANDwith-i <key> -o IdentitiesOnly=yes). The URL follows the credential (DMN-059): a credential only authorizes the transport it belongs to, so cloninghttps://github.com/org/appwith 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.registryauthorizes an image pull — a token plus username, sent to the Docker Engine as theX-Registry-Authheader (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; leavingappunset 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 forgithub.com/MyOrgauthorizes a clone ofgithub.com/myorg/appand the other way round. - Detection: git always runs with
GIT_TERMINAL_PROMPT=0andBatchMode=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 theknown_hostsof the user runningasc(root, for the daemon) fails the same way —BatchModehas no "continue connecting?" prompt to offer — so that error carries thessh-keyscan <host> >> ~/.ssh/known_hostsfix 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. Undersudothe 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 anasc 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 installreaches for the socket first — a private repository comes back as a structuredauth_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. Soasc install https://github.com/<user>/<private-repo>offers the key/token choice, writes it toasc auth, and installs — in one command, without a separateasc auth addround. - 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 withoutsudo. Instead the daemon resolves credentials on behalf of the caller: the peer uid (SO_PEERCRED, or the attributedSUDO_UIDfor a root peer) yields that user's home from the user database, and their~/.asc/auth.jsonis 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.jsonpointing at/root/.ssh/id_ed25519must 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 notypeand load asrepo— and are migrated toauth.jsonon the nextasc authwrite, 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 runningasc auth addover SSH.ListCredentials/UpsertCredential/RemoveCredentialoperate on the system store only (/etc/asc/auth.json) — a user's own~/.asc/auth.jsonstays exclusively under the CLI.UpsertCredentialupserts by(kind, pattern, app), same asGitAuth::addabove, 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-keysand registers aMethod::SshKeypointing at it — the same shapeasc auth add --ssh-keyproduces by hand. Replacing or removing that credential deletes the file too.ListCredentialsnever returns the secret itself, key file included. Ownership marker (managed_by):UpsertCredentialRequest.managed_bylets a caller stamp its own entries (the platform uses"platform"), andRemoveCredentialRequest.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 throughasc auth add.asc auth listprintsmanaged_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:
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 componentasc 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 prodis 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-webnames just the requested app, as for a single-app install. - A stack can declare dependencies between applications (
depends_on— the startup order); components can beoptional. Environment variables live in each app's ownasc.settings.yaml. - Registries and the platform store index both single
asc.yamlfiles andasc.stack.yamlstacks. - 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
namefrom the app's ownasc.yaml(in the example above the stack'swebapp may be namedmy-stack-web); the origin is recorded in meta.json aspackage: "my-stack/web"—asc app upgraderesolves the package through it. - Order: dependencies (
depends_on) install first; cycles are rejected at validation.asc install my-stackinstalls every non-optionalcomponent;asc install my-stack/dbinstalls the requested component (even anoptionalone) 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 +
pathrather than by registry spec, so before DMN-097 atype: stackpackage (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
registryrepo) — a hierarchy of JSON files: the root indexregistry.json→ category filescategories/<topic>.json(databases, ai, bots, game-servers, system-utilities, web…) → optional subcategories (children). Packages come in two kinds:app(asc.yaml) andstack(asc.stack.yaml). Validation schemas — inregistry/schema/. Descriptions are in English. - Source tree: the daemon's sourcelist → a tree is built from all registries (following the root index's
index/childrenlinks), 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 installin 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: thesourcefield of InstallAppRequest).asc app upgradeprefers the source the app was installed from. - Source types:
file://(a local directory) andhttps://(a registry, GitHub raw). - Fetch resilience (DMN-036): each registry index file (
registry.json, category files) is a separatecurlinvocation with no connection reuse between them — a wholeasc update/asc searchrun 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-updaterrelease assets). asc updateprogress (DMN-037): on a terminal, every index file gets its own progress line,docker pull/git clonestyle — 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|removewithout 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 listshows the origin of every source). Index caches: for root — indata_dir, for a user — in~/.cache/asc/.
- system
- Install policy (
[policy]in/etc/asc/config.toml, managed by root):user_install = "all"(default — users may install any packages: Docker, native, utilities) oruser_install = "docker"(users may install Docker applications only; native apps and utilities are root-only). Applied atasc install; does not apply to root. - Index cache with a TTL +
asc updatefor a forced refresh. - CLI:
asc install|remove|upgrade <pkg>(orasc install <git-url> [--branch|--tag] [--path <subdir>] [--app <stack app>] [--force]),asc search <query>,asc source add|remove|list,asc update.
🔗 Related tasks
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.