@saws/core 2.0.0-beta.2 → 2.0.0-beta.20
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/dist/.tsbuildinfo +1 -1
- package/dist/Host.d.ts +62 -0
- package/dist/Host.js +202 -0
- package/dist/ServiceDefinition.d.ts +66 -0
- package/dist/ServiceDefinition.js +182 -0
- package/dist/find-service-definition.d.ts +1 -8
- package/dist/find-service-definition.js +0 -6
- package/dist/get-saws-config.d.ts +14 -3
- package/dist/get-saws-config.js +16 -54
- package/dist/global-hosts.d.ts +8 -0
- package/dist/global-hosts.js +119 -0
- package/dist/host-readiness.d.ts +12 -0
- package/dist/host-readiness.js +230 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +5 -4
- package/dist/secrets-manager.d.ts +66 -0
- package/dist/secrets-manager.js +325 -0
- package/dist/utils/constants.d.ts +4 -0
- package/dist/utils/constants.js +5 -0
- package/dist/utils/create-directories.d.ts +2 -0
- package/dist/utils/create-directories.js +20 -0
- package/dist/utils/create-file-if-not-exists.d.ts +1 -0
- package/dist/utils/create-file-if-not-exists.js +12 -0
- package/dist/utils/dependency-management.d.ts +16 -0
- package/dist/utils/dependency-management.js +132 -0
- package/dist/utils/file-exists.d.ts +1 -0
- package/dist/utils/file-exists.js +12 -0
- package/dist/utils/generate-token.d.ts +1 -0
- package/dist/utils/generate-token.js +13 -0
- package/dist/utils/get-project-name.d.ts +1 -0
- package/dist/utils/get-project-name.js +5 -0
- package/dist/utils/get-service-path.js +1 -0
- package/dist/utils/list-files.d.ts +1 -0
- package/dist/utils/list-files.js +17 -0
- package/dist/utils/on-exit.d.ts +1 -0
- package/dist/utils/on-exit.js +14 -0
- package/dist/utils/parameterized-env-var-name.d.ts +1 -0
- package/dist/utils/parameterized-env-var-name.js +1 -0
- package/dist/utils/recursively-read-dir.d.ts +1 -0
- package/dist/utils/recursively-read-dir.js +15 -0
- package/dist/utils/retry-until.d.ts +1 -0
- package/dist/utils/retry-until.js +9 -0
- package/dist/utils/run-local.d.ts +9 -0
- package/dist/utils/run-local.js +87 -0
- package/dist/utils/shell-quote.d.ts +1 -0
- package/dist/utils/shell-quote.js +3 -0
- package/dist/utils/stage-outputs.d.ts +3 -0
- package/dist/utils/stage-outputs.js +24 -0
- package/dist/utils/uppercase.d.ts +1 -0
- package/dist/utils/uppercase.js +4 -0
- package/package.json +34 -21
- package/dist/context.d.ts +0 -41
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -38
- package/dist/context.test.d.ts +0 -2
- package/dist/context.test.d.ts.map +0 -1
- package/dist/context.test.js +0 -13
- package/dist/find-service-definition.d.ts.map +0 -1
- package/dist/find-service-definition.test.d.ts +0 -2
- package/dist/find-service-definition.test.d.ts.map +0 -1
- package/dist/find-service-definition.test.js +0 -44
- package/dist/get-saws-config.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/service-commands.d.ts +0 -10
- package/dist/service-commands.d.ts.map +0 -1
- package/dist/service-commands.js +0 -25
- package/dist/service-commands.test.d.ts +0 -2
- package/dist/service-commands.test.d.ts.map +0 -1
- package/dist/service-commands.test.js +0 -26
- package/dist/service-definition.d.ts +0 -36
- package/dist/service-definition.d.ts.map +0 -1
- package/dist/service-definition.js +0 -104
- package/dist/service-types.d.ts +0 -9
- package/dist/service-types.d.ts.map +0 -1
- /package/dist/{service-types.js → utils/get-service-path.d.ts} +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { writeStageOutputs } from "./utils/stage-outputs.js";
|
|
3
|
+
import { parameterizedEnvVarName } from "./utils/parameterized-env-var-name.js";
|
|
4
|
+
import { runLocal } from "./utils/run-local.js";
|
|
5
|
+
import { SecretReference } from "./secrets-manager.js";
|
|
6
|
+
export class ServiceDefinition {
|
|
7
|
+
name;
|
|
8
|
+
dependencies;
|
|
9
|
+
environment;
|
|
10
|
+
onDev;
|
|
11
|
+
onDeploy;
|
|
12
|
+
outputs = {};
|
|
13
|
+
deved = false;
|
|
14
|
+
deployed = false;
|
|
15
|
+
runtimeLogSink;
|
|
16
|
+
onDevAbortController = new AbortController();
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.name = config.name;
|
|
19
|
+
this.dependencies = config.dependencies ?? [];
|
|
20
|
+
this.environment = config.environment ?? {};
|
|
21
|
+
this.onDev = config.onDev ?? [];
|
|
22
|
+
this.onDeploy = config.onDeploy ?? [];
|
|
23
|
+
}
|
|
24
|
+
async init() {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
async dev() {
|
|
28
|
+
this.writeRuntimeLog(`Start dev ${this.name}\n`);
|
|
29
|
+
await this.forEachDependencyAsync(async (dependency) => {
|
|
30
|
+
if (dependency.deved)
|
|
31
|
+
return;
|
|
32
|
+
await dependency.dev();
|
|
33
|
+
dependency.deved = true;
|
|
34
|
+
});
|
|
35
|
+
this.startDevHooks();
|
|
36
|
+
}
|
|
37
|
+
async deploy(stage) {
|
|
38
|
+
console.log("Start deploy", this.name);
|
|
39
|
+
await this.forEachDependencyAsync(async (dependency) => {
|
|
40
|
+
if (dependency.deployed)
|
|
41
|
+
return;
|
|
42
|
+
await dependency.deploy(stage);
|
|
43
|
+
dependency.deployed = true;
|
|
44
|
+
});
|
|
45
|
+
await this.runHooks(this.onDeploy, "onDeploy");
|
|
46
|
+
}
|
|
47
|
+
async logs(_stage) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setRuntimeLogSink(sink) {
|
|
51
|
+
this.runtimeLogSink = sink;
|
|
52
|
+
this.forEachDependency((dependency) => dependency.setRuntimeLogSink(sink));
|
|
53
|
+
}
|
|
54
|
+
writeRuntimeLog(chunk, stream = "stdout") {
|
|
55
|
+
if (this.runtimeLogSink == null) {
|
|
56
|
+
const output = stream === "stderr" ? process.stderr : process.stdout;
|
|
57
|
+
output.write(chunk);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.runtimeLogSink({
|
|
61
|
+
serviceName: this.name,
|
|
62
|
+
stream,
|
|
63
|
+
chunk,
|
|
64
|
+
timestamp: new Date(),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
getRuntimeLogSink() {
|
|
68
|
+
return this.runtimeLogSink;
|
|
69
|
+
}
|
|
70
|
+
getOutputs() {
|
|
71
|
+
return this.outputs;
|
|
72
|
+
}
|
|
73
|
+
async setOutputs(outputs, stage) {
|
|
74
|
+
this.outputs = {
|
|
75
|
+
...this.outputs,
|
|
76
|
+
...outputs,
|
|
77
|
+
};
|
|
78
|
+
await writeStageOutputs({
|
|
79
|
+
[this.name]: this.outputs,
|
|
80
|
+
}, stage);
|
|
81
|
+
}
|
|
82
|
+
forEachDependency(callback) {
|
|
83
|
+
for (const dependency of this.dependencies) {
|
|
84
|
+
callback(dependency);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async forEachDependencyAsync(callback) {
|
|
88
|
+
for (const dependency of this.dependencies) {
|
|
89
|
+
await callback(dependency);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
getAllDependencies() {
|
|
93
|
+
const all = [this];
|
|
94
|
+
for (const dependency of this.dependencies) {
|
|
95
|
+
all.push(dependency);
|
|
96
|
+
all.push(...dependency.getAllDependencies());
|
|
97
|
+
}
|
|
98
|
+
return all;
|
|
99
|
+
}
|
|
100
|
+
getOnDevLogTabs() {
|
|
101
|
+
return this.onDev.map((hook, index) => this.getHookLogTabName("onDev", index, hook));
|
|
102
|
+
}
|
|
103
|
+
// this needs to be recursive down dependencies
|
|
104
|
+
exit() {
|
|
105
|
+
this.onDevAbortController.abort();
|
|
106
|
+
this.forEachDependency((dependency) => dependency.exit());
|
|
107
|
+
}
|
|
108
|
+
parameterizedEnvVarName(envVarName) {
|
|
109
|
+
return parameterizedEnvVarName(this.name, envVarName);
|
|
110
|
+
}
|
|
111
|
+
// this needs to be recursive down dependencies
|
|
112
|
+
async getEnvironmentVariables(stage, _target = "container") {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
async getDependenciesEnvironmentVariables(stage, target = "container") {
|
|
116
|
+
const environmentVariables = {};
|
|
117
|
+
await this.forEachDependencyAsync(async (definition) => {
|
|
118
|
+
Object.assign(environmentVariables, await definition.getEnvironmentVariables(stage, target));
|
|
119
|
+
});
|
|
120
|
+
return environmentVariables;
|
|
121
|
+
}
|
|
122
|
+
async getStageEnvironmentVariables(stage) {
|
|
123
|
+
const environment = this.environment[stage] ?? {};
|
|
124
|
+
return Object.fromEntries(await Promise.all(Object.entries(environment).map(async ([name, value]) => [
|
|
125
|
+
name,
|
|
126
|
+
value instanceof SecretReference ? await value.resolve({ stage }) : value,
|
|
127
|
+
])));
|
|
128
|
+
}
|
|
129
|
+
getStdOut() {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
getStdErr() {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
async runHooks(hooks, hookName) {
|
|
136
|
+
await Promise.all(hooks.map((hook, index) => this.runHook(hook, hookName, index)));
|
|
137
|
+
}
|
|
138
|
+
startDevHooks() {
|
|
139
|
+
for (const [index, hook] of this.onDev.entries()) {
|
|
140
|
+
void this.runHook(hook, "onDev", index, this.onDevAbortController.signal).catch((error) => {
|
|
141
|
+
if (this.onDevAbortController.signal.aborted)
|
|
142
|
+
return;
|
|
143
|
+
this.writeHookLog(this.getHookLogTabName("onDev", index, hook), `${error.message}\n`, "stderr");
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async runHook(hook, hookName, index, signal = new AbortController().signal) {
|
|
148
|
+
const serviceName = this.getHookLogTabName(hookName, index, hook);
|
|
149
|
+
if (typeof hook === "function") {
|
|
150
|
+
await hook({
|
|
151
|
+
signal,
|
|
152
|
+
log: (chunk, stream = "stdout") => this.writeHookLog(serviceName, chunk, stream),
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
await runLocal(hook, {
|
|
157
|
+
cwd: this.getServiceDirectory(),
|
|
158
|
+
logSink: this.runtimeLogSink,
|
|
159
|
+
serviceName,
|
|
160
|
+
signal,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
writeHookLog(serviceName, chunk, stream) {
|
|
164
|
+
if (this.runtimeLogSink == null) {
|
|
165
|
+
const output = stream === "stderr" ? process.stderr : process.stdout;
|
|
166
|
+
output.write(chunk);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.runtimeLogSink({ serviceName, stream, chunk, timestamp: new Date() });
|
|
170
|
+
}
|
|
171
|
+
getServiceDirectory() {
|
|
172
|
+
if (this.constructor === ServiceDefinition)
|
|
173
|
+
return process.cwd();
|
|
174
|
+
return path.resolve(this.name);
|
|
175
|
+
}
|
|
176
|
+
getHookLogTabName(hookName, index, hook) {
|
|
177
|
+
if (hookName === "onDev" && typeof hook === "string") {
|
|
178
|
+
return `${this.name}: ${hook}`;
|
|
179
|
+
}
|
|
180
|
+
return `${this.name}: ${hookName} ${index + 1}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
import type { ServiceDefinition } from "./
|
|
2
|
-
/**
|
|
3
|
-
* Finds one configured service by name in a dependency graph.
|
|
4
|
-
*
|
|
5
|
-
* Service names are CLI identifiers, so a name must resolve to exactly one
|
|
6
|
-
* service instance. Reusing the same instance in multiple branches is allowed.
|
|
7
|
-
*/
|
|
1
|
+
import type { ServiceDefinition } from "./ServiceDefinition.js";
|
|
8
2
|
export declare function findServiceDefinition(root: ServiceDefinition, name: string): ServiceDefinition;
|
|
9
|
-
//# sourceMappingURL=find-service-definition.d.ts.map
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Finds one configured service by name in a dependency graph.
|
|
3
|
-
*
|
|
4
|
-
* Service names are CLI identifiers, so a name must resolve to exactly one
|
|
5
|
-
* service instance. Reusing the same instance in multiple branches is allowed.
|
|
6
|
-
*/
|
|
7
1
|
export function findServiceDefinition(root, name) {
|
|
8
2
|
const matches = [];
|
|
9
3
|
const visited = new Set();
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
-
import type
|
|
2
|
-
export
|
|
3
|
-
|
|
1
|
+
import { type ServiceDefinition } from "./ServiceDefinition.js";
|
|
2
|
+
export type SawsCreate<TConfig = unknown> = (config: TConfig) => ServiceDefinition | Promise<ServiceDefinition>;
|
|
3
|
+
export type SawsConfigModule = {
|
|
4
|
+
default: ServiceDefinition;
|
|
5
|
+
create?: SawsCreate;
|
|
6
|
+
[name: string]: unknown;
|
|
7
|
+
};
|
|
8
|
+
export type SawsCreateModule<TConfig = unknown> = {
|
|
9
|
+
create: SawsCreate<TConfig>;
|
|
10
|
+
[name: string]: unknown;
|
|
11
|
+
};
|
|
12
|
+
export declare function getSawsConfigModule(path?: string): Promise<SawsConfigModule>;
|
|
13
|
+
export declare function getSawsConfig(path?: string): Promise<ServiceDefinition>;
|
|
14
|
+
export declare function createSawsConfig<TConfig>(module: SawsCreateModule<TConfig>, config: TConfig): Promise<ServiceDefinition>;
|
package/dist/get-saws-config.js
CHANGED
|
@@ -1,58 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export async function getSawsConfig(configPath) {
|
|
6
|
-
const resolvedPath = await resolveConfigPath(configPath);
|
|
7
|
-
const importPath = resolvedPath.endsWith(".ts")
|
|
8
|
-
? await transpileConfig(resolvedPath)
|
|
9
|
-
: resolvedPath;
|
|
10
|
-
try {
|
|
11
|
-
const moduleUrl = pathToFileURL(importPath).href;
|
|
12
|
-
const imported = await import(`${moduleUrl}?t=${Date.now()}`);
|
|
13
|
-
const serviceDefinition = imported.default ?? imported;
|
|
14
|
-
if (serviceDefinition == null || typeof serviceDefinition.deploy !== "function") {
|
|
15
|
-
throw new Error(`Expected ${resolvedPath} to export a ServiceDefinition as the default export`);
|
|
16
|
-
}
|
|
17
|
-
return serviceDefinition;
|
|
18
|
-
}
|
|
19
|
-
finally {
|
|
20
|
-
if (importPath !== resolvedPath) {
|
|
21
|
-
await rm(importPath, { force: true });
|
|
22
|
-
}
|
|
23
|
-
}
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
export async function getSawsConfigModule(path = "./saws.ts") {
|
|
3
|
+
const pathToConfig = resolve(path);
|
|
4
|
+
return (await import(pathToConfig));
|
|
24
5
|
}
|
|
25
|
-
async function
|
|
26
|
-
|
|
27
|
-
return path.resolve(configPath);
|
|
28
|
-
for (const candidate of ["./saws.ts", "./saws.js"]) {
|
|
29
|
-
const resolved = path.resolve(candidate);
|
|
30
|
-
try {
|
|
31
|
-
await access(resolved);
|
|
32
|
-
return resolved;
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
if (error.code !== "ENOENT")
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return path.resolve("./saws.ts");
|
|
6
|
+
export async function getSawsConfig(path = "./saws.ts") {
|
|
7
|
+
return (await getSawsConfigModule(path)).default;
|
|
40
8
|
}
|
|
41
|
-
async function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
9
|
+
export async function createSawsConfig(module, config) {
|
|
10
|
+
if (typeof module.create !== "function") {
|
|
11
|
+
throw new Error('Packaged SAWS applications must export a factory function named "create"');
|
|
12
|
+
}
|
|
13
|
+
const serviceDefinition = await module.create(config);
|
|
14
|
+
if (serviceDefinition == null ||
|
|
15
|
+
typeof serviceDefinition !== "object" ||
|
|
16
|
+
typeof serviceDefinition.deploy !== "function") {
|
|
17
|
+
throw new Error('The SAWS application "create" function must return a ServiceDefinition');
|
|
46
18
|
}
|
|
47
|
-
|
|
48
|
-
compilerOptions: {
|
|
49
|
-
target: ts.ScriptTarget.ES2022,
|
|
50
|
-
module: ts.ModuleKind.ES2022,
|
|
51
|
-
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
52
|
-
esModuleInterop: true,
|
|
53
|
-
},
|
|
54
|
-
fileName: configPath,
|
|
55
|
-
});
|
|
56
|
-
await writeFile(outputPath, transpiled.outputText);
|
|
57
|
-
return outputPath;
|
|
19
|
+
return serviceDefinition;
|
|
58
20
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Host, type HostConfig } from "./Host.js";
|
|
2
|
+
import { SecretsManager } from "./secrets-manager.js";
|
|
3
|
+
export type GlobalHostConfig = Omit<HostConfig, "sshPrivateKey" | "dryRun">;
|
|
4
|
+
export declare function getSawsHome(): string;
|
|
5
|
+
export declare function getGlobalHost(name: string): Host;
|
|
6
|
+
export declare function createGlobalHost(config: GlobalHostConfig): Promise<Host>;
|
|
7
|
+
export declare function getGlobalHostSecretsManager(): SecretsManager;
|
|
8
|
+
export declare function getGlobalHostProfilePath(name: string): string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Host, hostSshPrivateKeySecretName } from "./Host.js";
|
|
6
|
+
import { SecretsManager } from "./secrets-manager.js";
|
|
7
|
+
const GLOBAL_HOST_PROFILE_VERSION = 1;
|
|
8
|
+
const managers = new Map();
|
|
9
|
+
export function getSawsHome() {
|
|
10
|
+
const configuredHome = process.env.SAWS_HOME;
|
|
11
|
+
if (configuredHome != null && configuredHome.trim().length === 0) {
|
|
12
|
+
throw new Error("SAWS_HOME cannot be empty");
|
|
13
|
+
}
|
|
14
|
+
return path.resolve(configuredHome ?? path.join(os.homedir(), ".saws"));
|
|
15
|
+
}
|
|
16
|
+
export function getGlobalHost(name) {
|
|
17
|
+
const normalizedName = requireGlobalHostName(name);
|
|
18
|
+
const profilePath = getGlobalHostProfilePath(normalizedName);
|
|
19
|
+
let contents;
|
|
20
|
+
try {
|
|
21
|
+
contents = fs.readFileSync(profilePath, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code === "ENOENT") {
|
|
25
|
+
throw new Error(`Global host "${normalizedName}" is not configured. Run: saws host create ${normalizedName}`);
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const profile = parseGlobalHost(contents, profilePath);
|
|
30
|
+
if (profile.name !== normalizedName) {
|
|
31
|
+
throw new Error(`Global host profile ${profilePath} declares name "${profile.name}" instead of "${normalizedName}"`);
|
|
32
|
+
}
|
|
33
|
+
return createHost(profile);
|
|
34
|
+
}
|
|
35
|
+
export async function createGlobalHost(config) {
|
|
36
|
+
const host = createHost({
|
|
37
|
+
...config,
|
|
38
|
+
name: requireGlobalHostName(config.name),
|
|
39
|
+
});
|
|
40
|
+
const profile = toStoredGlobalHost(host);
|
|
41
|
+
const profilePath = getGlobalHostProfilePath(host.name);
|
|
42
|
+
await mkdir(path.dirname(profilePath), { recursive: true });
|
|
43
|
+
try {
|
|
44
|
+
await writeFile(profilePath, `${JSON.stringify(profile, null, 2)}\n`, { flag: "wx" });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code === "EEXIST") {
|
|
48
|
+
throw new Error(`Global host "${host.name}" is already configured`);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
return host;
|
|
53
|
+
}
|
|
54
|
+
export function getGlobalHostSecretsManager() {
|
|
55
|
+
const sawsHome = getSawsHome();
|
|
56
|
+
let manager = managers.get(sawsHome);
|
|
57
|
+
if (manager == null) {
|
|
58
|
+
manager = new SecretsManager({
|
|
59
|
+
rootDir: sawsHome,
|
|
60
|
+
sawsDirectory: sawsHome,
|
|
61
|
+
});
|
|
62
|
+
managers.set(sawsHome, manager);
|
|
63
|
+
}
|
|
64
|
+
return manager;
|
|
65
|
+
}
|
|
66
|
+
export function getGlobalHostProfilePath(name) {
|
|
67
|
+
return path.join(getSawsHome(), "hosts", requireGlobalHostName(name), "host.json");
|
|
68
|
+
}
|
|
69
|
+
function createHost(config) {
|
|
70
|
+
const manager = getGlobalHostSecretsManager();
|
|
71
|
+
return new Host({
|
|
72
|
+
...config,
|
|
73
|
+
sshPrivateKey: manager.global.reference(hostSshPrivateKeySecretName(config.name)),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function toStoredGlobalHost(host) {
|
|
77
|
+
return {
|
|
78
|
+
version: GLOBAL_HOST_PROFILE_VERSION,
|
|
79
|
+
name: host.name,
|
|
80
|
+
address: host.address,
|
|
81
|
+
user: host.user,
|
|
82
|
+
sshPort: host.sshPort,
|
|
83
|
+
exposure: host.exposure,
|
|
84
|
+
allowedTcpPorts: host.allowedTcpPorts,
|
|
85
|
+
...(host.platform == null ? {} : { platform: host.platform }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function parseGlobalHost(contents, profilePath) {
|
|
89
|
+
let value;
|
|
90
|
+
try {
|
|
91
|
+
value = JSON.parse(contents);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error(`Global host profile ${profilePath} is not valid JSON`);
|
|
95
|
+
}
|
|
96
|
+
if (value == null || typeof value !== "object") {
|
|
97
|
+
throw new Error(`Global host profile ${profilePath} must contain an object`);
|
|
98
|
+
}
|
|
99
|
+
const profile = value;
|
|
100
|
+
if (profile.version !== GLOBAL_HOST_PROFILE_VERSION) {
|
|
101
|
+
throw new Error(`Global host profile ${profilePath} has an unsupported version`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof profile.name !== "string") {
|
|
104
|
+
throw new Error(`Global host profile ${profilePath} must contain a name`);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const host = createHost(profile);
|
|
108
|
+
return toStoredGlobalHost(host);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
throw new Error(`Global host profile ${profilePath} is invalid: ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function requireGlobalHostName(name) {
|
|
115
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) {
|
|
116
|
+
throw new Error("Global host name must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens");
|
|
117
|
+
}
|
|
118
|
+
return name;
|
|
119
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type HostExposure = "tunnel" | "public";
|
|
2
|
+
export interface HostReadinessConfig {
|
|
3
|
+
name: string;
|
|
4
|
+
deploymentUser: string;
|
|
5
|
+
deploymentPublicKey: string;
|
|
6
|
+
exposure: HostExposure;
|
|
7
|
+
sshPort: number;
|
|
8
|
+
allowedTcpPorts: number[];
|
|
9
|
+
}
|
|
10
|
+
export declare function getReadinessHash(config: HostReadinessConfig): string;
|
|
11
|
+
export declare function readinessCheckScript(config: HostReadinessConfig): string;
|
|
12
|
+
export declare function readinessConfigureScript(config: HostReadinessConfig): string;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { shellQuote } from "./utils/shell-quote.js";
|
|
3
|
+
const READINESS_VERSION = 4;
|
|
4
|
+
const READINESS_DIRECTORY = "/etc/saws";
|
|
5
|
+
const READINESS_FILE = `${READINESS_DIRECTORY}/host-readiness`;
|
|
6
|
+
const DEFAULT_APP_DIRECTORY = "/opt/saws";
|
|
7
|
+
export function getReadinessHash(config) {
|
|
8
|
+
return createHash("sha256")
|
|
9
|
+
.update(JSON.stringify({
|
|
10
|
+
version: READINESS_VERSION,
|
|
11
|
+
deploymentUser: config.deploymentUser,
|
|
12
|
+
deploymentPublicKeyHash: createHash("sha256")
|
|
13
|
+
.update(config.deploymentPublicKey)
|
|
14
|
+
.digest("hex"),
|
|
15
|
+
exposure: config.exposure,
|
|
16
|
+
sshPort: config.sshPort,
|
|
17
|
+
allowedTcpPorts: config.allowedTcpPorts,
|
|
18
|
+
}))
|
|
19
|
+
.digest("hex");
|
|
20
|
+
}
|
|
21
|
+
export function readinessCheckScript(config) {
|
|
22
|
+
const hash = getReadinessHash(config);
|
|
23
|
+
const configureCommand = `saws host configure ${config.name}`;
|
|
24
|
+
return `expected=${shellQuote(hash)}
|
|
25
|
+
actual=$(cat ${shellQuote(READINESS_FILE)} 2>/dev/null || true)
|
|
26
|
+
if [ "$actual" != "$expected" ]; then
|
|
27
|
+
echo ${shellQuote(`Host "${config.name}" is not configured for this deployment. Run: ${configureCommand}`)} >&2
|
|
28
|
+
exit 78
|
|
29
|
+
fi
|
|
30
|
+
deployment_user=${shellQuote(config.deploymentUser)}
|
|
31
|
+
deployment_public_key=${shellQuote(config.deploymentPublicKey)}
|
|
32
|
+
deployment_home=$(getent passwd "$deployment_user" | cut -d: -f6)
|
|
33
|
+
if [ -z "$deployment_home" ] ||
|
|
34
|
+
! grep -Fqx -- "$deployment_public_key" "$deployment_home/.ssh/authorized_keys" 2>/dev/null; then
|
|
35
|
+
echo ${shellQuote(`Deployment SSH access has drifted on host "${config.name}". Run: ${configureCommand}`)} >&2
|
|
36
|
+
exit 78
|
|
37
|
+
fi
|
|
38
|
+
if [ ! -d ${shellQuote(DEFAULT_APP_DIRECTORY)} ] || [ ! -w ${shellQuote(DEFAULT_APP_DIRECTORY)} ]; then
|
|
39
|
+
echo ${shellQuote(`Deployment directory ${DEFAULT_APP_DIRECTORY} is not writable on host "${config.name}". Run: ${configureCommand}`)} >&2
|
|
40
|
+
exit 78
|
|
41
|
+
fi
|
|
42
|
+
if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
|
|
43
|
+
echo ${shellQuote(`Docker is not ready on host "${config.name}". Run: ${configureCommand}`)} >&2
|
|
44
|
+
exit 78
|
|
45
|
+
fi
|
|
46
|
+
if ! systemctl is-active --quiet docker ||
|
|
47
|
+
! systemctl is-active --quiet fail2ban ||
|
|
48
|
+
! systemctl is-active --quiet ufw ||
|
|
49
|
+
! systemctl is-active --quiet saws-docker-firewall ||
|
|
50
|
+
[ ! -r /etc/ssh/sshd_config.d/00-saws-hardening.conf ] ||
|
|
51
|
+
[ ! -r /etc/sysctl.d/60-saws-hardening.conf ] ||
|
|
52
|
+
[ ! -r /etc/apt/apt.conf.d/20auto-upgrades ] ||
|
|
53
|
+
[ ! -x /usr/local/sbin/saws-docker-firewall ]; then
|
|
54
|
+
echo ${shellQuote(`Host "${config.name}" has drifted from the required security baseline. Run: ${configureCommand}`)} >&2
|
|
55
|
+
exit 78
|
|
56
|
+
fi`;
|
|
57
|
+
}
|
|
58
|
+
export function readinessConfigureScript(config) {
|
|
59
|
+
const applicationPorts = config.allowedTcpPorts.join(" ");
|
|
60
|
+
const firewallPorts = [config.sshPort, ...config.allowedTcpPorts]
|
|
61
|
+
.filter((port, index, ports) => ports.indexOf(port) === index)
|
|
62
|
+
.join(" ");
|
|
63
|
+
const hash = getReadinessHash(config);
|
|
64
|
+
return `export DEBIAN_FRONTEND=noninteractive
|
|
65
|
+
deployment_user=${shellQuote(config.deploymentUser)}
|
|
66
|
+
deployment_public_key=${shellQuote(config.deploymentPublicKey)}
|
|
67
|
+
if [ -z "$deployment_user" ] || [ -z "$deployment_public_key" ]; then
|
|
68
|
+
echo "Deployment user and public key are required" >&2
|
|
69
|
+
exit 1
|
|
70
|
+
fi
|
|
71
|
+
if [ "$(uname -s)" != "Linux" ] || [ ! -r /etc/os-release ]; then
|
|
72
|
+
echo "SAWS host configuration supports Debian and Ubuntu Linux only" >&2
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
. /etc/os-release
|
|
76
|
+
case "\${ID:-}" in
|
|
77
|
+
debian|ubuntu) ;;
|
|
78
|
+
*)
|
|
79
|
+
echo "SAWS host configuration does not support \${PRETTY_NAME:-this operating system}" >&2
|
|
80
|
+
exit 1
|
|
81
|
+
;;
|
|
82
|
+
esac
|
|
83
|
+
|
|
84
|
+
apt-get update
|
|
85
|
+
apt-get install -y ca-certificates fail2ban ufw unattended-upgrades
|
|
86
|
+
if ! command -v docker >/dev/null 2>&1; then
|
|
87
|
+
apt-get install -y docker.io
|
|
88
|
+
fi
|
|
89
|
+
systemctl enable --now docker
|
|
90
|
+
docker info >/dev/null
|
|
91
|
+
|
|
92
|
+
if ! id "$deployment_user" >/dev/null 2>&1; then
|
|
93
|
+
useradd --create-home --shell /bin/bash "$deployment_user"
|
|
94
|
+
fi
|
|
95
|
+
if [ "$deployment_user" != root ]; then
|
|
96
|
+
usermod -aG docker "$deployment_user"
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
deployment_home=$(getent passwd "$deployment_user" | cut -d: -f6)
|
|
100
|
+
deployment_group=$(id -gn "$deployment_user")
|
|
101
|
+
if [ -z "$deployment_home" ] || [ ! -d "$deployment_home" ]; then
|
|
102
|
+
echo "Deployment user $deployment_user does not have a usable home directory" >&2
|
|
103
|
+
exit 1
|
|
104
|
+
fi
|
|
105
|
+
chown "$deployment_user:$deployment_group" "$deployment_home"
|
|
106
|
+
install -d -m 0700 -o "$deployment_user" -g "$deployment_group" "$deployment_home/.ssh"
|
|
107
|
+
install -d -m 0755 -o "$deployment_user" -g "$deployment_group" ${shellQuote(DEFAULT_APP_DIRECTORY)}
|
|
108
|
+
authorized_keys="$deployment_home/.ssh/authorized_keys"
|
|
109
|
+
touch "$authorized_keys"
|
|
110
|
+
chown "$deployment_user:$deployment_group" "$authorized_keys"
|
|
111
|
+
chmod 0600 "$authorized_keys"
|
|
112
|
+
if ! grep -Fqx -- "$deployment_public_key" "$authorized_keys"; then
|
|
113
|
+
printf '%s\\n' "$deployment_public_key" >> "$authorized_keys"
|
|
114
|
+
fi
|
|
115
|
+
if ! grep -Fqx -- "$deployment_public_key" "$authorized_keys"; then
|
|
116
|
+
echo "Refusing to disable SSH passwords: deployment public key installation failed" >&2
|
|
117
|
+
exit 1
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
install -d -m 0755 /etc/ssh/sshd_config.d
|
|
121
|
+
rm -f /etc/ssh/sshd_config.d/60-saws-hardening.conf
|
|
122
|
+
cat > /etc/ssh/sshd_config.d/00-saws-hardening.conf <<'EOF'
|
|
123
|
+
PasswordAuthentication no
|
|
124
|
+
KbdInteractiveAuthentication no
|
|
125
|
+
PermitEmptyPasswords no
|
|
126
|
+
PermitRootLogin prohibit-password
|
|
127
|
+
PubkeyAuthentication yes
|
|
128
|
+
X11Forwarding no
|
|
129
|
+
MaxAuthTries 3
|
|
130
|
+
EOF
|
|
131
|
+
sshd -t
|
|
132
|
+
sshd -T | grep -qx 'passwordauthentication no'
|
|
133
|
+
sshd -T | grep -qx 'kbdinteractiveauthentication no'
|
|
134
|
+
sshd -T | grep -qx 'pubkeyauthentication yes'
|
|
135
|
+
|
|
136
|
+
cat > /etc/sysctl.d/60-saws-hardening.conf <<'EOF'
|
|
137
|
+
net.ipv4.conf.all.accept_redirects=0
|
|
138
|
+
net.ipv4.conf.default.accept_redirects=0
|
|
139
|
+
net.ipv4.conf.all.send_redirects=0
|
|
140
|
+
net.ipv4.conf.default.send_redirects=0
|
|
141
|
+
net.ipv4.conf.all.rp_filter=1
|
|
142
|
+
net.ipv4.conf.default.rp_filter=1
|
|
143
|
+
net.ipv4.tcp_syncookies=1
|
|
144
|
+
net.ipv6.conf.all.accept_redirects=0
|
|
145
|
+
net.ipv6.conf.default.accept_redirects=0
|
|
146
|
+
EOF
|
|
147
|
+
sysctl --system >/dev/null
|
|
148
|
+
|
|
149
|
+
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
|
|
150
|
+
APT::Periodic::Update-Package-Lists "1";
|
|
151
|
+
APT::Periodic::Unattended-Upgrade "1";
|
|
152
|
+
EOF
|
|
153
|
+
install -d -m 0755 /etc/fail2ban/jail.d
|
|
154
|
+
cat > /etc/fail2ban/jail.d/saws.conf <<EOF
|
|
155
|
+
[sshd]
|
|
156
|
+
enabled = true
|
|
157
|
+
port = ${config.sshPort}
|
|
158
|
+
EOF
|
|
159
|
+
systemctl enable --now fail2ban
|
|
160
|
+
systemctl restart fail2ban
|
|
161
|
+
|
|
162
|
+
ufw --force reset
|
|
163
|
+
ufw default deny incoming
|
|
164
|
+
ufw default allow outgoing
|
|
165
|
+
for port in ${firewallPorts}; do
|
|
166
|
+
ufw allow "$port/tcp"
|
|
167
|
+
done
|
|
168
|
+
ufw --force enable
|
|
169
|
+
|
|
170
|
+
cat > /usr/local/sbin/saws-docker-firewall <<EOF
|
|
171
|
+
#!/bin/sh
|
|
172
|
+
set -eu
|
|
173
|
+
application_ports="${applicationPorts}"
|
|
174
|
+
|
|
175
|
+
external_interface=\\$(ip -4 route list default | awk 'NR == 1 { print \\$5 }')
|
|
176
|
+
if [ -z "\\$external_interface" ]; then
|
|
177
|
+
echo "Could not determine the host's external network interface" >&2
|
|
178
|
+
exit 1
|
|
179
|
+
fi
|
|
180
|
+
iptables -N DOCKER-USER 2>/dev/null || true
|
|
181
|
+
iptables -N SAWS-DOCKER 2>/dev/null || true
|
|
182
|
+
iptables -F SAWS-DOCKER
|
|
183
|
+
iptables -A SAWS-DOCKER -i "\\$external_interface" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
184
|
+
for port in \\$application_ports; do
|
|
185
|
+
iptables -A SAWS-DOCKER -i "\\$external_interface" -p tcp -m conntrack --ctorigdstport "\\$port" -j ACCEPT
|
|
186
|
+
done
|
|
187
|
+
iptables -A SAWS-DOCKER -i "\\$external_interface" -j DROP
|
|
188
|
+
iptables -A SAWS-DOCKER -j RETURN
|
|
189
|
+
iptables -C DOCKER-USER -j SAWS-DOCKER 2>/dev/null || iptables -I DOCKER-USER 1 -j SAWS-DOCKER
|
|
190
|
+
|
|
191
|
+
if ip6tables -nL DOCKER-USER >/dev/null 2>&1; then
|
|
192
|
+
external_interface6=\\$(ip -6 route list default | awk 'NR == 1 { print \\$5 }')
|
|
193
|
+
if [ -n "\\$external_interface6" ]; then
|
|
194
|
+
ip6tables -N SAWS-DOCKER 2>/dev/null || true
|
|
195
|
+
ip6tables -F SAWS-DOCKER
|
|
196
|
+
ip6tables -A SAWS-DOCKER -i "\\$external_interface6" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
197
|
+
for port in \\$application_ports; do
|
|
198
|
+
ip6tables -A SAWS-DOCKER -i "\\$external_interface6" -p tcp -m conntrack --ctorigdstport "\\$port" -j ACCEPT
|
|
199
|
+
done
|
|
200
|
+
ip6tables -A SAWS-DOCKER -i "\\$external_interface6" -j DROP
|
|
201
|
+
ip6tables -A SAWS-DOCKER -j RETURN
|
|
202
|
+
ip6tables -C DOCKER-USER -j SAWS-DOCKER 2>/dev/null || ip6tables -I DOCKER-USER 1 -j SAWS-DOCKER
|
|
203
|
+
fi
|
|
204
|
+
fi
|
|
205
|
+
EOF
|
|
206
|
+
chmod 0755 /usr/local/sbin/saws-docker-firewall
|
|
207
|
+
cat > /etc/systemd/system/saws-docker-firewall.service <<'EOF'
|
|
208
|
+
[Unit]
|
|
209
|
+
Description=SAWS Docker firewall rules
|
|
210
|
+
After=network-online.target docker.service ufw.service
|
|
211
|
+
Wants=network-online.target docker.service
|
|
212
|
+
|
|
213
|
+
[Service]
|
|
214
|
+
Type=oneshot
|
|
215
|
+
RemainAfterExit=yes
|
|
216
|
+
ExecStart=/usr/local/sbin/saws-docker-firewall
|
|
217
|
+
|
|
218
|
+
[Install]
|
|
219
|
+
WantedBy=multi-user.target
|
|
220
|
+
EOF
|
|
221
|
+
systemctl daemon-reload
|
|
222
|
+
systemctl enable saws-docker-firewall
|
|
223
|
+
systemctl restart saws-docker-firewall
|
|
224
|
+
|
|
225
|
+
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
226
|
+
install -d -m 0755 ${shellQuote(READINESS_DIRECTORY)}
|
|
227
|
+
printf '%s\\n' ${shellQuote(hash)} > ${shellQuote(READINESS_FILE)}
|
|
228
|
+
chmod 0644 ${shellQuote(READINESS_FILE)}
|
|
229
|
+
echo "SAWS host configuration complete"`;
|
|
230
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export * from "./
|
|
1
|
+
export * from "./ServiceDefinition.js";
|
|
2
2
|
export * from "./find-service-definition.js";
|
|
3
3
|
export * from "./get-saws-config.js";
|
|
4
|
-
export * from "./
|
|
5
|
-
export * from "./
|
|
6
|
-
export * from "./
|
|
7
|
-
|
|
4
|
+
export * from "./Host.js";
|
|
5
|
+
export * from "./global-hosts.js";
|
|
6
|
+
export * from "./host-readiness.js";
|
|
7
|
+
export * from "./secrets-manager.js";
|