@yunuseon/kayra 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ansible/playbooks/edge.yml +2 -0
- package/ansible/roles/common/tasks/main.yml +2 -0
- package/ansible/roles/forgejo-config/tasks/main.yml +47 -4
- package/ansible/roles/platform/tasks/main.yml +6 -0
- package/cli/dist/cli.js +12 -6
- package/cli/dist/commands/server.js +33 -16
- package/cli/dist/commands/validate.js +84 -0
- package/cli/dist/fleet.js +21 -4
- package/cli/dist/gather.js +57 -0
- package/cli/dist/load.js +18 -3
- package/cli/dist/main.js +14 -0
- package/cli/dist/schemas.js +53 -0
- package/cli/dist/settings.js +137 -0
- package/cli/dist/validate.js +50 -0
- package/compose/docker-compose.yml +29 -1
- package/compose/traefik/traefik.yml +2 -1
- package/package.json +7 -4
- package/schema/app-values.json +15 -0
- package/schema/app.json +45 -0
- package/schema/defaults.json +534 -0
- package/schema/server.json +71 -0
- package/schema/vars-edge.json +121 -0
- package/schema/vars-nas.json +124 -0
- package/schema/vars-platform.json +333 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** The server kinds, each with its playbook; settings say which kinds read them. */
|
|
3
|
+
export const serverKinds = ["platform", "edge", "nas"];
|
|
4
|
+
const setting = (name, kinds, schema, description) => ({ name, kinds, secret: false, schema: schema.describe(description), description });
|
|
5
|
+
const secret = (name, kinds, schema, description) => ({ name, kinds, secret: true, schema: schema.describe(description), description });
|
|
6
|
+
const text = z.string();
|
|
7
|
+
const texts = z.array(z.string());
|
|
8
|
+
const platform = ["platform"];
|
|
9
|
+
const edge = ["edge"];
|
|
10
|
+
const nas = ["nas"];
|
|
11
|
+
/** Nested settings check the fields they know and allow others: a typo is caught at the top. */
|
|
12
|
+
const record = (fields) => z.looseObject(fields);
|
|
13
|
+
export const settings = [
|
|
14
|
+
// Every kind that serves HTTPS
|
|
15
|
+
setting("acme_email", ["platform", "edge"], text, "The Let's Encrypt account contact: expiry warnings land here."),
|
|
16
|
+
setting("docker_version", ["platform", "edge"], text, "The Docker CE major the docker role pins and holds, as an apt version glob (default 5:29*)."),
|
|
17
|
+
// platform: the platform role
|
|
18
|
+
setting("git_remote_url", platform, text, "kayra's repository: Jenkins loads the pipeline library and platform jobs from it."),
|
|
19
|
+
setting("platform_ref", platform, text, "The ref of git_remote_url Jenkins loads from; defaults to the tag of the kayra version that ran (v<version>). Only a test server sets a branch."),
|
|
20
|
+
setting("jenkins_admin_user", platform, text, "Jenkins' local admin user (default admin)."),
|
|
21
|
+
setting("authelia_policy", platform, z.enum(["one_factor", "two_factor"]), "What Authelia asks of staff: two_factor (default) or one_factor."),
|
|
22
|
+
setting("authelia_smtp_address", platform, text, "SMTP server for Authelia's mails (passkey enrolment); empty: mails go to a file."),
|
|
23
|
+
setting("authelia_smtp_username", platform, text, "SMTP user for Authelia."),
|
|
24
|
+
setting("authelia_smtp_sender", platform, text, "Sender address of Authelia's mails."),
|
|
25
|
+
setting("acme_dns", platform, z.enum(["", "hetzner"]), "Certificates by DNS-01 instead of HTTP-01, for a server the internet never reaches: the DNS provider (hetzner)."),
|
|
26
|
+
setting("renovate_repositories", platform, texts, "owner/repo of the repos this server's platform-renovate keeps up to date; the job exists only where the list is not empty."),
|
|
27
|
+
setting("renovate_schedule", platform, text, "When platform-renovate runs, as a Jenkins cron spec (default weekly: H 4 * * 1)."),
|
|
28
|
+
setting("renovate_platform", platform, z.enum(["github", "forgejo"]), "Where the repos live: github (default) or forgejo."),
|
|
29
|
+
setting("renovate_endpoint", platform, text, "With a Forgejo: its API on platform-internal, e.g. http://forgejo:3000/api/v1."),
|
|
30
|
+
setting("renovate_git_url", platform, z.enum(["default", "ssh", "endpoint"]), "How Renovate clones; endpoint with a Forgejo, so it uses the internal address."),
|
|
31
|
+
setting("renovate_host_rules", platform, z.array(record({ matchHost: text })), "Renovate hostRules: allowInternal for a Forgejo on platform-internal, credentials for a private registry."),
|
|
32
|
+
setting("vm", platform, record({ address: text, gateway: text, disk: text }), "target home: the VM kayra provision builds — fixed address (CIDR), gateway, disk size."),
|
|
33
|
+
setting("forgejo_config", platform, record({
|
|
34
|
+
url: text,
|
|
35
|
+
container: text,
|
|
36
|
+
runners: z.array(record({ name: text, scope: text.optional() })).optional(),
|
|
37
|
+
tokens: z.array(record({ user: text, name: text, scopes: texts, secret: text })).optional(),
|
|
38
|
+
users: z.array(record({ name: text, email: text })).optional(),
|
|
39
|
+
orgs: z.array(record({ name: text, owner: text, visibility: z.enum(["public", "limited", "private"]) })).optional(),
|
|
40
|
+
ssh_keys: z.array(record({ user: text, title: text, key: text })).optional(),
|
|
41
|
+
repos: z
|
|
42
|
+
.array(record({
|
|
43
|
+
name: text,
|
|
44
|
+
settings: z.record(z.string(), z.unknown()).optional(),
|
|
45
|
+
collaborators: z.array(record({ user: text, permission: text })).optional(),
|
|
46
|
+
webhooks: texts.optional(),
|
|
47
|
+
push_mirror: text.optional(),
|
|
48
|
+
pull_mirror: text.optional(),
|
|
49
|
+
}))
|
|
50
|
+
.optional(),
|
|
51
|
+
}), "What this server's own Forgejo holds, as code: users, SSH keys, organizations, runners, repos (settings, collaborators, webhooks, mirrors) and the tokens it mints for the platform."),
|
|
52
|
+
// platform and nas: the compose-stacks role
|
|
53
|
+
setting("compose_stacks", ["platform", "nas"], record({ dir: text, stacks: texts }), "The server's own compose stacks: their directory on the server and the names under stacks/ to run."),
|
|
54
|
+
// nas
|
|
55
|
+
setting("nas_root", nas, text, "The only directory the NAS play writes in, asserted before any change (e.g. /volume2)."),
|
|
56
|
+
setting("restic_backup", nas, record({
|
|
57
|
+
dir: text,
|
|
58
|
+
repository: text,
|
|
59
|
+
schedule: text,
|
|
60
|
+
restore_test_schedule: text,
|
|
61
|
+
paths: texts,
|
|
62
|
+
restore_test_paths: texts.optional(),
|
|
63
|
+
exclude: texts.optional(),
|
|
64
|
+
keep: record({ daily: z.number(), weekly: z.number(), monthly: z.number() }).optional(),
|
|
65
|
+
ssh: record({ public_key: text, host: text, user: text, port: z.number(), known_host: text }).optional(),
|
|
66
|
+
}), "The nightly restic backup: what, where to (SFTP), when, how long kept, and the monthly restore test."),
|
|
67
|
+
// edge
|
|
68
|
+
setting("wireguard", edge, record({ address: text, port: z.number(), peer_public_key: text, peer_allowed_ips: texts, keepalive: z.number().optional() }), "The tunnel into the private network: the edge's address, port, and the one peer (its key, what it may reach)."),
|
|
69
|
+
setting("edge_routes", edge, z.array(record({ subdomains: texts, upstream: text, forwarded_headers: z.boolean().optional() })), "Public names the edge serves: subdomains of the domain and the upstream inside the tunnel they go to."),
|
|
70
|
+
setting("backup_check", edge, record({
|
|
71
|
+
stale_after_hours: z.number(),
|
|
72
|
+
github_repository: text,
|
|
73
|
+
github_app_id: text,
|
|
74
|
+
repositories: z.array(record({ name: text, url: text, user: text, restore_test_max_days: z.number().optional() })),
|
|
75
|
+
}), "The hourly check of the private network's backups: when a snapshot is stale, where issues go, which repositories."),
|
|
76
|
+
// Secrets
|
|
77
|
+
secret("jenkins_admin_password", platform, text, "Jenkins' admin password."),
|
|
78
|
+
secret("git_token", platform, text, "Token Jenkins clones and posts commit statuses with."),
|
|
79
|
+
secret("github_webhook_secret", platform, text, "HMAC secret of the apps' GitHub push webhooks."),
|
|
80
|
+
secret("renovate_token", platform, text, "Token platform-renovate opens PRs with."),
|
|
81
|
+
secret("renovate_github_com_token", platform, text, "Public-read GitHub token for Renovate's lookups off GitHub."),
|
|
82
|
+
secret("authelia_session_secret", platform, text, "Authelia's session secret."),
|
|
83
|
+
secret("authelia_storage_encryption_key", platform, text, "Authelia's storage encryption key."),
|
|
84
|
+
secret("authelia_smtp_password", platform, text, "Authelia's SMTP password."),
|
|
85
|
+
secret("authelia_users", platform, z.record(z.string(), z.unknown()), "Staff accounts: name, email, argon2id password hash."),
|
|
86
|
+
secret("acme_dns_token", platform, text, "With acme_dns: the DNS provider's token, for DNS-01 only."),
|
|
87
|
+
secret("forgejo_admin_token", platform, text, "forgejo-config's token: admin, organization, repository, user (read and write)."),
|
|
88
|
+
secret("forgejo_runner_secrets", platform, z.record(z.string(), text), "Per runner name: the 40-character secret shared by the runner and Forgejo."),
|
|
89
|
+
secret("forgejo_action_secrets", platform, z.array(record({ repo: text, name: text, value: text })), "Actions secrets per repo."),
|
|
90
|
+
secret("forgejo_push_mirror_credentials", platform, record({ username: text, password: text }), "The account push mirrors push with."),
|
|
91
|
+
secret("compose_stack_secrets", ["platform", "nas"], z.record(z.string(), z.record(z.string(), z.unknown())), "Per stack: the variables of its .env."),
|
|
92
|
+
secret("compose_registry_logins", ["platform", "nas"], z.array(record({ registry: text, username: text, password: text })), "Private registries the stacks pull from."),
|
|
93
|
+
secret("restic_backup_password", nas, text, "The restic repository's password."),
|
|
94
|
+
secret("restic_backup_ssh_key", nas, text, "The SSH key the backup reaches the Storage Box with."),
|
|
95
|
+
secret("restic_backup_postgres_passwords", nas, z.record(z.string(), text), "Per database container: the password its dump connects with."),
|
|
96
|
+
secret("wireguard_private_key", edge, text, "The edge's WireGuard private key."),
|
|
97
|
+
secret("wireguard_peer_psk", edge, text, "The tunnel's preshared key."),
|
|
98
|
+
secret("backup_check_github_app_key", edge, text, "The GitHub App's private key the backup check opens issues with."),
|
|
99
|
+
secret("backup_check_passwords", edge, z.record(z.string(), text), "Per watched repository: its read-only password."),
|
|
100
|
+
];
|
|
101
|
+
/** Ansible's own variables (connection, privilege escalation) are always allowed. */
|
|
102
|
+
export const isAnsibleVariable = (key) => key.startsWith("ansible_");
|
|
103
|
+
export const settingsFor = (kind, secrets) => settings.filter((each) => each.secret === secrets && each.kinds.includes(kind));
|
|
104
|
+
const shape = (chosen) => Object.fromEntries(chosen.map((each) => [each.name, each.schema.optional()]));
|
|
105
|
+
/** vars.yml for a kind: its settings and nothing else (Ansible's own variables apart). */
|
|
106
|
+
export const varsSchema = (kind) => z.strictObject(shape(settingsFor(kind, false)));
|
|
107
|
+
/** defaults.yml: settings shared by every server, so any kind's. */
|
|
108
|
+
export const defaultsSchema = z.strictObject(shape(settings.filter((each) => !each.secret)));
|
|
109
|
+
/** secrets.yml: the known secrets typed; other keys may be helpers that vars.yml references. */
|
|
110
|
+
export const secretsSchema = (kind) => z.looseObject(shape(settingsFor(kind, true)));
|
|
111
|
+
/** server.yml: topology only. */
|
|
112
|
+
export const serverSchema = z.strictObject({
|
|
113
|
+
kind: z.enum(serverKinds).optional().describe("Which playbook: platform (default; Traefik, Authelia, Jenkins, apps), edge or nas."),
|
|
114
|
+
target: z.enum(["hetzner", "multipass", "home"]).describe("Where it runs: hetzner, multipass (a local VM) or home (a machine nothing creates)."),
|
|
115
|
+
domain: z.string().describe("The server's domain; platform subdomains and the apps' hang off it."),
|
|
116
|
+
vault: z.boolean().optional().describe("secrets.yml is vault-encrypted (required outside multipass)."),
|
|
117
|
+
vault_password_file: z.string().optional().describe("The vault password file; without it, Ansible asks."),
|
|
118
|
+
apps: z
|
|
119
|
+
.array(z.strictObject({
|
|
120
|
+
name: z.string(),
|
|
121
|
+
repo: z.string().describe("The app's repository, https://."),
|
|
122
|
+
ref: z.string().optional().describe("The branch to build and deploy (default main)."),
|
|
123
|
+
path: z.string().optional().describe("The app's root inside the repository (default .)."),
|
|
124
|
+
}))
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("The apps this server runs (platform only)."),
|
|
127
|
+
});
|
|
128
|
+
/** app.yml, at the root of an app's repository: the app contract. */
|
|
129
|
+
export const appManifestSchema = z.strictObject({
|
|
130
|
+
name: z.string().describe("The app's name, as in the server.yml entry."),
|
|
131
|
+
compose: z.array(z.string()).describe("Compose files, relative to the app root, in order."),
|
|
132
|
+
subdomains: z.array(z.string()).describe("Hostnames the app claims: <sub>.<domain>."),
|
|
133
|
+
secrets: z.array(z.string()).optional().describe("Secret keys; their values come from the fleet's apps/<app>.secrets.yml."),
|
|
134
|
+
config: z.array(z.string()).optional().describe("Config keys; their values come from the fleet's apps/<app>.yml."),
|
|
135
|
+
});
|
|
136
|
+
/** apps/<app>.yml and apps/<app>.secrets.yml: flat key → value. */
|
|
137
|
+
export const appValuesSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { err, ok } from "./result.js";
|
|
2
|
+
import { isAnsibleVariable, settings } from "./settings.js";
|
|
3
|
+
/** Edit distance, for "did you mean". */
|
|
4
|
+
const indices = (length) => [...Array(length).keys()];
|
|
5
|
+
const distance = (a, b) => indices(b.length)
|
|
6
|
+
.reduce((row, j) => indices(a.length).reduce((next, i) => [
|
|
7
|
+
...next,
|
|
8
|
+
Math.min((row[i + 1] ?? 0) + 1, (next[i] ?? 0) + 1, (row[i] ?? 0) + (a.charAt(i) === b.charAt(j) ? 0 : 1)),
|
|
9
|
+
], [j + 1]), indices(a.length + 1))
|
|
10
|
+
.at(-1) ?? 0;
|
|
11
|
+
const suggestion = (key, known) => {
|
|
12
|
+
const closest = known
|
|
13
|
+
.map((name) => ({ name, cost: distance(key, name) }))
|
|
14
|
+
.toSorted((x, y) => x.cost - y.cost)
|
|
15
|
+
.at(0);
|
|
16
|
+
return closest !== undefined && closest.cost <= Math.max(2, Math.floor(key.length / 4)) ? ` — did you mean '${closest.name}'?` : "";
|
|
17
|
+
};
|
|
18
|
+
/** The first problem Zod found, said for the file it is in. */
|
|
19
|
+
const firstIssue = (file, error, known, unknownKey) => {
|
|
20
|
+
const issue = error.issues.at(0);
|
|
21
|
+
if (issue === undefined)
|
|
22
|
+
return `${file} is invalid`;
|
|
23
|
+
if (issue.code === "unrecognized_keys") {
|
|
24
|
+
const key = issue.keys.at(0) ?? "";
|
|
25
|
+
const where = issue.path.length === 0 ? "" : ` (in ${issue.path.join(".")})`;
|
|
26
|
+
return `${file}: '${key}' ${unknownKey}${where}${issue.path.length === 0 ? suggestion(key, known) : ""}`;
|
|
27
|
+
}
|
|
28
|
+
return `${file}: ${issue.path.length === 0 ? "" : `${issue.path.join(".")}: `}${issue.message}`;
|
|
29
|
+
};
|
|
30
|
+
const secretNames = settings.filter((each) => each.secret).map((each) => each.name);
|
|
31
|
+
/**
|
|
32
|
+
* A settings file (vars.yml, defaults.yml) against its schema: a secret in it
|
|
33
|
+
* is refused first, then unknown keys and wrong types. Ansible's own variables
|
|
34
|
+
* are always allowed.
|
|
35
|
+
*/
|
|
36
|
+
export const checkSettings = (file, values, schema, known) => {
|
|
37
|
+
const leaked = Object.keys(values).find((key) => secretNames.includes(key));
|
|
38
|
+
if (leaked !== undefined)
|
|
39
|
+
return err(`${file}: '${leaked}' is a secret — it goes in secrets.yml`);
|
|
40
|
+
const own = Object.fromEntries(Object.entries(values).filter(([key]) => !isAnsibleVariable(key)));
|
|
41
|
+
const parsed = schema.safeParse(own);
|
|
42
|
+
return parsed.success ? ok(values) : err(firstIssue(file, parsed.error, known, "is not a setting"));
|
|
43
|
+
};
|
|
44
|
+
/** The names a kind's vars.yml may set. */
|
|
45
|
+
export const knownSettings = (kind) => settings.filter((each) => !each.secret && (kind === undefined || each.kinds.includes(kind))).map((each) => each.name);
|
|
46
|
+
/** A YAML document against a schema, for files whose keys are not settings (server.yml, app.yml). */
|
|
47
|
+
export const checkShape = (file, values, schema) => {
|
|
48
|
+
const parsed = schema.safeParse(values);
|
|
49
|
+
return parsed.success ? ok(values) : err(firstIssue(file, parsed.error, [], "is not a key it knows"));
|
|
50
|
+
};
|
|
@@ -10,8 +10,10 @@ services:
|
|
|
10
10
|
ports:
|
|
11
11
|
- "80:80"
|
|
12
12
|
- "443:443"
|
|
13
|
+
# No Docker socket: containers are read through socket-proxy. A mount,
|
|
14
|
+
# even :ro, would be the whole Docker API, i.e. root on the host, for the
|
|
15
|
+
# one service the internet talks to.
|
|
13
16
|
volumes:
|
|
14
|
-
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
15
17
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
|
16
18
|
- ./traefik/dynamic:/etc/traefik/dynamic:ro
|
|
17
19
|
- ./traefik/certs:/certs:ro
|
|
@@ -19,6 +21,7 @@ services:
|
|
|
19
21
|
networks:
|
|
20
22
|
- public
|
|
21
23
|
- internal
|
|
24
|
+
- socket
|
|
22
25
|
labels:
|
|
23
26
|
# The dashboard used to ride on an unauthenticated published :8080.
|
|
24
27
|
# It speaks no auth of its own, which is exactly what forwardAuth is for.
|
|
@@ -29,6 +32,27 @@ services:
|
|
|
29
32
|
- "traefik.http.routers.dashboard.service=api@internal"
|
|
30
33
|
- "traefik.http.routers.dashboard.middlewares=authelia@file,security-headers@file"
|
|
31
34
|
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Traefik's view of Docker: the socket behind an allow-list. Read-only
|
|
37
|
+
# (POST off), and only containers, events, version and ping — what the
|
|
38
|
+
# Docker provider asks for. Reachable on platform-socket only, a network
|
|
39
|
+
# with no route out that nothing but Traefik joins.
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
socket-proxy:
|
|
42
|
+
image: tecnativa/docker-socket-proxy:v0.5.0
|
|
43
|
+
restart: unless-stopped
|
|
44
|
+
environment:
|
|
45
|
+
- CONTAINERS=1
|
|
46
|
+
- EVENTS=1
|
|
47
|
+
- VERSION=1
|
|
48
|
+
- PING=1
|
|
49
|
+
- POST=0
|
|
50
|
+
volumes:
|
|
51
|
+
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
52
|
+
mem_limit: 64m
|
|
53
|
+
networks:
|
|
54
|
+
- socket
|
|
55
|
+
|
|
32
56
|
# ---------------------------------------------------------------------------
|
|
33
57
|
# Authelia — single sign-on for services that sit behind Traefik.
|
|
34
58
|
#
|
|
@@ -75,6 +99,10 @@ networks:
|
|
|
75
99
|
name: platform-public
|
|
76
100
|
internal:
|
|
77
101
|
name: platform-internal
|
|
102
|
+
# Traefik and socket-proxy only; internal, so the proxy has no way out.
|
|
103
|
+
socket:
|
|
104
|
+
name: platform-socket
|
|
105
|
+
internal: true
|
|
78
106
|
|
|
79
107
|
volumes:
|
|
80
108
|
acme-data:
|
|
@@ -35,7 +35,8 @@ certificatesResolvers:
|
|
|
35
35
|
|
|
36
36
|
providers:
|
|
37
37
|
docker:
|
|
38
|
-
|
|
38
|
+
# The read-only socket proxy (compose: socket-proxy), not the socket.
|
|
39
|
+
endpoint: "tcp://socket-proxy:2375"
|
|
39
40
|
exposedByDefault: false
|
|
40
41
|
network: platform-public
|
|
41
42
|
file:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunuseon/kayra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A self-hosted app platform and the CLI that runs it: Traefik, Authelia, Jenkins, a registry and rootless BuildKit per server, set up with OpenTofu and Ansible.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Yunus Öztürk",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"terraform/",
|
|
24
24
|
"scripts/",
|
|
25
25
|
"cloud-init.yml.tpl",
|
|
26
|
-
"hosts.conf"
|
|
26
|
+
"hosts.conf",
|
|
27
|
+
"schema/"
|
|
27
28
|
],
|
|
28
29
|
"publishConfig": {
|
|
29
30
|
"access": "public",
|
|
@@ -35,12 +36,14 @@
|
|
|
35
36
|
"prepack": "npm run build",
|
|
36
37
|
"typecheck": "tsc -p cli --noEmit",
|
|
37
38
|
"lint": "cd cli && eslint --flag unstable_native_nodejs_ts_config .",
|
|
38
|
-
"test": "node --test 'cli/test/**/*.test.ts'"
|
|
39
|
+
"test": "node --test 'cli/test/**/*.test.ts'",
|
|
40
|
+
"schemas": "node cli/scripts/schemas.ts"
|
|
39
41
|
},
|
|
40
42
|
"dependencies": {
|
|
41
43
|
"@optique/core": "1.3.3",
|
|
42
44
|
"@optique/run": "1.3.3",
|
|
43
|
-
"yaml": "2.9.1"
|
|
45
|
+
"yaml": "2.9.1",
|
|
46
|
+
"zod": "4.6.5"
|
|
44
47
|
},
|
|
45
48
|
"devDependencies": {
|
|
46
49
|
"@eslint/js": "10.0.1",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"propertyNames": {
|
|
5
|
+
"type": "string"
|
|
6
|
+
},
|
|
7
|
+
"additionalProperties": {
|
|
8
|
+
"type": [
|
|
9
|
+
"string",
|
|
10
|
+
"number",
|
|
11
|
+
"boolean"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"title": "apps/<app>.yml and apps/<app>.secrets.yml"
|
|
15
|
+
}
|
package/schema/app.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"name": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"description": "The app's name, as in the server.yml entry."
|
|
8
|
+
},
|
|
9
|
+
"compose": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "string"
|
|
13
|
+
},
|
|
14
|
+
"description": "Compose files, relative to the app root, in order."
|
|
15
|
+
},
|
|
16
|
+
"subdomains": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"items": {
|
|
19
|
+
"type": "string"
|
|
20
|
+
},
|
|
21
|
+
"description": "Hostnames the app claims: <sub>.<domain>."
|
|
22
|
+
},
|
|
23
|
+
"secrets": {
|
|
24
|
+
"description": "Secret keys; their values come from the fleet's apps/<app>.secrets.yml.",
|
|
25
|
+
"type": "array",
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "string"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"config": {
|
|
31
|
+
"description": "Config keys; their values come from the fleet's apps/<app>.yml.",
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": {
|
|
34
|
+
"type": "string"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"required": [
|
|
39
|
+
"name",
|
|
40
|
+
"compose",
|
|
41
|
+
"subdomains"
|
|
42
|
+
],
|
|
43
|
+
"additionalProperties": false,
|
|
44
|
+
"title": "app.yml"
|
|
45
|
+
}
|