@highstate/common 0.9.18 → 0.9.19
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/{chunk-YYNV3MVT.js → chunk-WDYIUWYZ.js} +484 -176
- package/dist/chunk-WDYIUWYZ.js.map +1 -0
- package/dist/highstate.manifest.json +12 -8
- package/dist/index.js +1 -1
- package/dist/units/access-point/index.js +16 -0
- package/dist/units/access-point/index.js.map +1 -0
- package/dist/units/databases/existing-mariadb/index.js +17 -0
- package/dist/units/databases/existing-mariadb/index.js.map +1 -0
- package/dist/units/databases/existing-mongodb/index.js +17 -0
- package/dist/units/databases/existing-mongodb/index.js.map +1 -0
- package/dist/units/databases/existing-postgresql/index.js +17 -0
- package/dist/units/databases/existing-postgresql/index.js.map +1 -0
- package/dist/units/dns/record-set/index.js +22 -11
- package/dist/units/dns/record-set/index.js.map +1 -1
- package/dist/units/existing-server/index.js +9 -9
- package/dist/units/existing-server/index.js.map +1 -1
- package/dist/units/network/l3-endpoint/index.js +1 -1
- package/dist/units/network/l4-endpoint/index.js +1 -1
- package/dist/units/script/index.js +1 -1
- package/dist/units/server-dns/index.js +1 -1
- package/dist/units/server-patch/index.js +1 -1
- package/dist/units/ssh/key-pair/index.js +4 -3
- package/dist/units/ssh/key-pair/index.js.map +1 -1
- package/package.json +61 -8
- package/src/shared/access-point.ts +110 -0
- package/src/shared/command.ts +81 -14
- package/src/shared/dns.ts +150 -90
- package/src/shared/files.ts +23 -18
- package/src/shared/gateway.ts +117 -0
- package/src/shared/impl-ref.ts +123 -0
- package/src/shared/index.ts +4 -0
- package/src/shared/network.ts +39 -25
- package/src/shared/passwords.ts +3 -3
- package/src/shared/ssh.ts +109 -124
- package/src/shared/tls.ts +123 -0
- package/src/units/access-point/index.ts +12 -0
- package/src/units/databases/existing-mariadb/index.ts +14 -0
- package/src/units/databases/existing-mongodb/index.ts +14 -0
- package/src/units/databases/existing-postgresql/index.ts +14 -0
- package/src/units/dns/record-set/index.ts +21 -11
- package/src/units/existing-server/index.ts +9 -15
- package/src/units/ssh/key-pair/index.ts +4 -3
- package/dist/chunk-YYNV3MVT.js.map +0 -1
package/src/shared/dns.ts
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
import type { ArrayPatchMode, dns, network } from "@highstate/library"
|
2
|
+
import { getOrCreate, z } from "@highstate/contract"
|
2
3
|
import {
|
3
4
|
ComponentResource,
|
4
|
-
|
5
|
+
interpolate,
|
6
|
+
normalizeInputsAndMap,
|
5
7
|
output,
|
6
|
-
Output,
|
7
|
-
Resource,
|
8
|
+
type Output,
|
8
9
|
toPromise,
|
9
10
|
type Input,
|
10
11
|
type InputArray,
|
@@ -12,7 +13,7 @@ import {
|
|
12
13
|
type ResourceOptions,
|
13
14
|
type Unwrap,
|
14
15
|
} from "@highstate/pulumi"
|
15
|
-
import {
|
16
|
+
import { flat, groupBy, uniqueBy } from "remeda"
|
16
17
|
import { Command, type CommandHost } from "./command"
|
17
18
|
import {
|
18
19
|
filterEndpoints,
|
@@ -21,6 +22,13 @@ import {
|
|
21
22
|
parseL3Endpoint,
|
22
23
|
type InputL3Endpoint,
|
23
24
|
} from "./network"
|
25
|
+
import { ImplementationMediator } from "./impl-ref"
|
26
|
+
|
27
|
+
export const dnsRecordMediator = new ImplementationMediator(
|
28
|
+
"dns-record",
|
29
|
+
z.object({ name: z.string(), args: z.custom<ResolvedDnsRecordArgs>() }),
|
30
|
+
z.instanceof(ComponentResource),
|
31
|
+
)
|
24
32
|
|
25
33
|
export type DnsRecordArgs = {
|
26
34
|
/**
|
@@ -74,23 +82,23 @@ export type DnsRecordArgs = {
|
|
74
82
|
waitAt?: InputOrArray<CommandHost>
|
75
83
|
}
|
76
84
|
|
77
|
-
export type ResolvedDnsRecordArgs =
|
85
|
+
export type ResolvedDnsRecordArgs = Pick<DnsRecordArgs, "name" | "priority" | "ttl" | "proxied"> & {
|
78
86
|
/**
|
79
87
|
* The value of the DNS record.
|
80
88
|
*/
|
81
|
-
value: string
|
89
|
+
value: Input<string>
|
82
90
|
|
83
91
|
/**
|
84
92
|
* The type of the DNS record.
|
85
93
|
*/
|
86
|
-
type: string
|
94
|
+
type: Input<string>
|
87
95
|
}
|
88
96
|
|
89
97
|
export type DnsRecordSetArgs = Omit<DnsRecordArgs, "provider" | "value"> & {
|
90
98
|
/**
|
91
99
|
* The DNS providers to use to create the DNS records.
|
92
100
|
*
|
93
|
-
* If multiple providers matched the specified domain,
|
101
|
+
* If multiple providers matched the specified domain, records will be created for each of them.
|
94
102
|
*/
|
95
103
|
providers: Input<dns.Provider[]>
|
96
104
|
|
@@ -116,125 +124,177 @@ function getTypeByEndpoint(endpoint: network.L3Endpoint): string {
|
|
116
124
|
}
|
117
125
|
}
|
118
126
|
|
119
|
-
|
127
|
+
/**
|
128
|
+
* Creates a DNS record for the specified value and waits for it to be resolved.
|
129
|
+
*
|
130
|
+
* Uses the specified DNS provider to create the record.
|
131
|
+
*/
|
132
|
+
export class DnsRecord extends ComponentResource {
|
120
133
|
/**
|
121
134
|
* The underlying dns record resource.
|
122
135
|
*/
|
123
|
-
|
136
|
+
readonly dnsRecord: Output<ComponentResource>
|
124
137
|
|
125
138
|
/**
|
126
|
-
* The
|
139
|
+
* The commands to be executed after the DNS record is created/updated.
|
127
140
|
*
|
128
|
-
*
|
141
|
+
* These commands will wait for the DNS record to be resolved to the specified value.
|
129
142
|
*/
|
130
|
-
|
143
|
+
readonly waitCommands: Output<Command[]>
|
131
144
|
|
132
|
-
|
145
|
+
constructor(name: string, args: DnsRecordArgs, opts?: ResourceOptions) {
|
133
146
|
super("highstate:common:DnsRecord", name, args, opts)
|
134
147
|
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
)
|
148
|
+
const l3Endpoint = output(args.value).apply(value => parseL3Endpoint(value))
|
149
|
+
const resolvedValue = l3Endpoint.apply(l3EndpointToString)
|
150
|
+
const resolvedType = args.type ? output(args.type) : l3Endpoint.apply(getTypeByEndpoint)
|
151
|
+
|
152
|
+
this.dnsRecord = output(args.provider).apply(provider => {
|
153
|
+
return dnsRecordMediator.call(provider.implRef, {
|
154
|
+
name,
|
155
|
+
args: {
|
156
|
+
name: args.name,
|
157
|
+
priority: args.priority,
|
158
|
+
ttl: args.ttl,
|
159
|
+
value: resolvedValue,
|
160
|
+
type: resolvedType,
|
161
|
+
},
|
162
|
+
})
|
150
163
|
})
|
151
164
|
|
152
|
-
this.waitCommands = output(
|
153
|
-
|
154
|
-
|
155
|
-
|
165
|
+
this.waitCommands = output({
|
166
|
+
waitAt: args.waitAt,
|
167
|
+
resolvedType,
|
168
|
+
proxied: args.proxied,
|
169
|
+
}).apply(({ waitAt, resolvedType, proxied }) => {
|
170
|
+
if (resolvedType === "CNAME") {
|
171
|
+
// TODO: handle CNAME records
|
172
|
+
return []
|
173
|
+
}
|
174
|
+
|
175
|
+
const resolvedHosts = waitAt ? [waitAt].flat() : []
|
176
|
+
|
177
|
+
if (proxied) {
|
178
|
+
// for proxied records do not verify the value since we do not know the actual IP addressa
|
179
|
+
|
180
|
+
return (resolvedHosts as Unwrap<CommandHost>[]).map(host => {
|
181
|
+
const hostname = host === "local" ? "local" : host.hostname
|
182
|
+
|
183
|
+
return new Command(
|
184
|
+
`${name}.wait-for-dns.${hostname}`,
|
185
|
+
{
|
186
|
+
host,
|
187
|
+
create: [
|
188
|
+
interpolate`while ! getent hosts "${args.name}";`,
|
189
|
+
interpolate`do echo "Waiting for DNS record \"${args.name}\" to be available...";`,
|
190
|
+
`sleep 5;`,
|
191
|
+
`done`,
|
192
|
+
],
|
193
|
+
},
|
194
|
+
{ parent: this },
|
195
|
+
)
|
196
|
+
})
|
197
|
+
}
|
198
|
+
|
199
|
+
return (resolvedHosts as Unwrap<CommandHost>[]).map(host => {
|
156
200
|
const hostname = host === "local" ? "local" : host.hostname
|
157
201
|
|
158
202
|
return new Command(
|
159
|
-
`${name}-
|
203
|
+
`${name}.wait-for-dns.${hostname}`,
|
160
204
|
{
|
161
205
|
host,
|
162
|
-
create:
|
163
|
-
|
206
|
+
create: [
|
207
|
+
interpolate`while ! getent hosts "${args.name}" | grep "${resolvedValue}";`,
|
208
|
+
interpolate`do echo "Waiting for DNS record \"${args.name}" to resolve to "${resolvedValue}"...";`,
|
209
|
+
`sleep 5;`,
|
210
|
+
`done`,
|
211
|
+
],
|
164
212
|
},
|
165
213
|
{ parent: this },
|
166
214
|
)
|
167
215
|
})
|
168
216
|
})
|
169
217
|
}
|
170
|
-
|
171
|
-
protected abstract create(
|
172
|
-
name: string,
|
173
|
-
args: ResolvedDnsRecordArgs,
|
174
|
-
opts?: ResourceOptions,
|
175
|
-
): Input<Resource>
|
176
|
-
|
177
|
-
static create(name: string, args: DnsRecordArgs, opts?: ResourceOptions): Output<DnsRecord> {
|
178
|
-
return output(args).apply(async args => {
|
179
|
-
const providerType = args.provider.type
|
180
|
-
const implName = `${capitalize(providerType)}DnsRecord`
|
181
|
-
const implModule = (await import(`@highstate/${providerType}`)) as Record<string, unknown>
|
182
|
-
|
183
|
-
const implClass = implModule[implName] as new (
|
184
|
-
name: string,
|
185
|
-
args: Unwrap<DnsRecordArgs>,
|
186
|
-
opts?: ResourceOptions,
|
187
|
-
) => DnsRecord
|
188
|
-
|
189
|
-
return new implClass(name, args, opts)
|
190
|
-
})
|
191
|
-
}
|
192
218
|
}
|
193
219
|
|
220
|
+
/**
|
221
|
+
* Creates a set of DNS records for the specified values and waits for them to be resolved.
|
222
|
+
*/
|
194
223
|
export class DnsRecordSet extends ComponentResource {
|
195
224
|
/**
|
196
225
|
* The underlying dns record resources.
|
197
226
|
*/
|
198
|
-
|
227
|
+
readonly dnsRecords: Output<DnsRecord[]>
|
199
228
|
|
200
229
|
/**
|
201
|
-
* The
|
230
|
+
* The flat list of all wait commands for the DNS records.
|
202
231
|
*/
|
203
|
-
|
232
|
+
readonly waitCommands: Output<Command[]>
|
204
233
|
|
205
|
-
|
206
|
-
super("highstate:common:DnsRecordSet", name,
|
234
|
+
constructor(name: string, args: DnsRecordSetArgs, opts?: ResourceOptions) {
|
235
|
+
super("highstate:common:DnsRecordSet", name, args, opts)
|
207
236
|
|
208
|
-
|
237
|
+
const matchedProviders = output({
|
238
|
+
providers: args.providers,
|
239
|
+
name: args.name ?? name,
|
240
|
+
}).apply(({ providers }) => {
|
241
|
+
const matchedProviders = providers.filter(provider => name.endsWith(provider.domain))
|
209
242
|
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
}
|
243
|
+
if (matchedProviders.length === 0) {
|
244
|
+
throw new Error(`No DNS provider matched the domain "${name}"`)
|
245
|
+
}
|
214
246
|
|
215
|
-
|
216
|
-
const records = output(args).apply(args => {
|
217
|
-
const recordName = args.name ?? name
|
218
|
-
const values = normalize(args.value, args.values)
|
219
|
-
|
220
|
-
return output(
|
221
|
-
args.providers
|
222
|
-
.filter(provider => recordName.endsWith(provider.domain))
|
223
|
-
.flatMap(provider => {
|
224
|
-
return values.map(value => {
|
225
|
-
const l3Endpoint = parseL3Endpoint(value)
|
226
|
-
|
227
|
-
return DnsRecord.create(
|
228
|
-
`${provider.type}-from-${recordName}-to-${l3EndpointToString(l3Endpoint)}`,
|
229
|
-
{ name: recordName, ...args, value: l3Endpoint, provider },
|
230
|
-
opts,
|
231
|
-
)
|
232
|
-
})
|
233
|
-
}),
|
234
|
-
)
|
247
|
+
return matchedProviders
|
235
248
|
})
|
236
249
|
|
237
|
-
|
250
|
+
this.dnsRecords = normalizeInputsAndMap(args.value, args.values, value => {
|
251
|
+
return output({
|
252
|
+
name: args.name ?? name,
|
253
|
+
providers: matchedProviders,
|
254
|
+
}).apply(({ name, providers }) => {
|
255
|
+
return providers.flatMap(provider => {
|
256
|
+
const l3Endpoint = parseL3Endpoint(value)
|
257
|
+
|
258
|
+
return new DnsRecord(
|
259
|
+
`${name}.${provider.id}.${l3EndpointToString(l3Endpoint)}`,
|
260
|
+
{
|
261
|
+
name,
|
262
|
+
provider,
|
263
|
+
value: l3Endpoint,
|
264
|
+
type: args.type ?? getTypeByEndpoint(l3Endpoint),
|
265
|
+
proxied: args.proxied,
|
266
|
+
ttl: args.ttl,
|
267
|
+
priority: args.priority,
|
268
|
+
waitAt: args.waitAt,
|
269
|
+
},
|
270
|
+
{ parent: this },
|
271
|
+
)
|
272
|
+
})
|
273
|
+
})
|
274
|
+
}).apply(flat)
|
275
|
+
|
276
|
+
this.waitCommands = this.dnsRecords
|
277
|
+
.apply(records => records.flatMap(record => record.waitCommands))
|
278
|
+
.apply(flat)
|
279
|
+
}
|
280
|
+
|
281
|
+
private static readonly dnsRecordSetCache = new Map<string, DnsRecordSet>()
|
282
|
+
|
283
|
+
/**
|
284
|
+
* Creates a DNS record set for the specified endpoints and waits for it to be resolved.
|
285
|
+
*
|
286
|
+
* If a DNS record set with the same name already exists, it will be reused.
|
287
|
+
*
|
288
|
+
* @param name The name of the DNS record set.
|
289
|
+
* @param args The arguments for the DNS record set.
|
290
|
+
* @param opts The options for the resource.
|
291
|
+
*/
|
292
|
+
static createOnce(name: string, args: DnsRecordSetArgs, opts?: ResourceOptions): DnsRecordSet {
|
293
|
+
return getOrCreate(
|
294
|
+
DnsRecordSet.dnsRecordSetCache,
|
295
|
+
name,
|
296
|
+
() => new DnsRecordSet(name, args, opts),
|
297
|
+
)
|
238
298
|
}
|
239
299
|
}
|
240
300
|
|
@@ -268,7 +328,7 @@ export async function updateEndpointsWithFqdn<TEndpoint extends network.L34Endpo
|
|
268
328
|
|
269
329
|
const filteredEndpoints = filterEndpoints(resolvedEndpoints, fqdnEndpointFilter)
|
270
330
|
|
271
|
-
const dnsRecordSet = DnsRecordSet
|
331
|
+
const dnsRecordSet = new DnsRecordSet(fqdn, {
|
272
332
|
providers: dnsProviders,
|
273
333
|
values: filteredEndpoints,
|
274
334
|
waitAt: "local",
|
package/src/shared/files.ts
CHANGED
@@ -1,18 +1,17 @@
|
|
1
1
|
import type { common, network } from "@highstate/library"
|
2
|
-
import {
|
3
|
-
import { cp, mkdir, mkdtemp, rm, writeFile, rename, stat } from "node:fs/promises"
|
4
|
-
import { join, extname, basename, dirname } from "node:path"
|
2
|
+
import { createHash } from "node:crypto"
|
5
3
|
import { createReadStream } from "node:fs"
|
6
|
-
import {
|
4
|
+
import { cp, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises"
|
5
|
+
import { tmpdir } from "node:os"
|
6
|
+
import { basename, dirname, extname, join } from "node:path"
|
7
7
|
import { Readable } from "node:stream"
|
8
|
-
import {
|
8
|
+
import { pipeline } from "node:stream/promises"
|
9
|
+
import { type CommonObjectMeta, type File, HighstateSignature } from "@highstate/contract"
|
10
|
+
import { asset, toPromise } from "@highstate/pulumi"
|
9
11
|
import { minimatch } from "minimatch"
|
10
|
-
|
11
|
-
import { HighstateSignature, type File } from "@highstate/contract"
|
12
|
-
import { asset, toPromise, type ObjectMeta } from "@highstate/pulumi"
|
13
12
|
import * as tar from "tar"
|
14
13
|
import unzipper from "unzipper"
|
15
|
-
import { l7EndpointToString, parseL7Endpoint
|
14
|
+
import { type InputL7Endpoint, l7EndpointToString, parseL7Endpoint } from "./network"
|
16
15
|
|
17
16
|
export type FolderPackOptions = {
|
18
17
|
/**
|
@@ -50,7 +49,7 @@ export function assetFromFile(file: common.File): asset.Asset {
|
|
50
49
|
)
|
51
50
|
}
|
52
51
|
|
53
|
-
if (file.
|
52
|
+
if (file.content.isBinary) {
|
54
53
|
throw new Error(
|
55
54
|
"Cannot create asset from inline binary file content. Please open an issue if you need this feature.",
|
56
55
|
)
|
@@ -171,12 +170,16 @@ export class MaterializedFile implements AsyncDisposable {
|
|
171
170
|
private _path!: string
|
172
171
|
private _disposed = false
|
173
172
|
|
174
|
-
readonly artifactMeta:
|
173
|
+
readonly artifactMeta: CommonObjectMeta
|
175
174
|
|
176
175
|
constructor(
|
177
176
|
readonly entity: common.File,
|
178
177
|
readonly parent?: MaterializedFolder,
|
179
|
-
) {
|
178
|
+
) {
|
179
|
+
this.artifactMeta = {
|
180
|
+
title: `Materialized file "${entity.meta.name}"`,
|
181
|
+
}
|
182
|
+
}
|
180
183
|
|
181
184
|
get path(): string {
|
182
185
|
return this._path
|
@@ -196,7 +199,7 @@ export class MaterializedFile implements AsyncDisposable {
|
|
196
199
|
|
197
200
|
switch (this.entity.content.type) {
|
198
201
|
case "embedded": {
|
199
|
-
const content = this.entity.
|
202
|
+
const content = this.entity.content.isBinary
|
200
203
|
? Buffer.from(this.entity.content.value, "base64")
|
201
204
|
: this.entity.content.value
|
202
205
|
|
@@ -301,7 +304,6 @@ export class MaterializedFile implements AsyncDisposable {
|
|
301
304
|
name: this.entity.meta.name,
|
302
305
|
mode: fileStats.mode & 0o777, // extract only permission bits
|
303
306
|
size: fileStats.size,
|
304
|
-
isBinary: this.entity.meta.isBinary, // keep original binary flag as we can't reliably detect this from filesystem
|
305
307
|
}
|
306
308
|
|
307
309
|
// return file entity with artifact content using actual filesystem stats
|
@@ -338,7 +340,6 @@ export class MaterializedFile implements AsyncDisposable {
|
|
338
340
|
name,
|
339
341
|
mode,
|
340
342
|
size: 0,
|
341
|
-
isBinary: false,
|
342
343
|
},
|
343
344
|
content: {
|
344
345
|
type: "embedded",
|
@@ -389,12 +390,16 @@ export class MaterializedFolder implements AsyncDisposable {
|
|
389
390
|
|
390
391
|
private readonly _disposables: AsyncDisposable[] = []
|
391
392
|
|
392
|
-
readonly artifactMeta:
|
393
|
+
readonly artifactMeta: CommonObjectMeta
|
393
394
|
|
394
395
|
constructor(
|
395
396
|
readonly entity: common.Folder,
|
396
397
|
readonly parent?: MaterializedFolder,
|
397
|
-
) {
|
398
|
+
) {
|
399
|
+
this.artifactMeta = {
|
400
|
+
title: `Materialized folder "${entity.meta.name}"`,
|
401
|
+
}
|
402
|
+
}
|
398
403
|
|
399
404
|
get path(): string {
|
400
405
|
return this._path
|
@@ -708,7 +713,7 @@ export async function fetchFileSize(endpoint: network.L7Endpoint): Promise<numbe
|
|
708
713
|
}
|
709
714
|
|
710
715
|
const size = parseInt(contentLength, 10)
|
711
|
-
if (isNaN(size)) {
|
716
|
+
if (Number.isNaN(size)) {
|
712
717
|
throw new Error(`Invalid Content-Length value: ${contentLength}`)
|
713
718
|
}
|
714
719
|
|
@@ -0,0 +1,117 @@
|
|
1
|
+
import type { TlsCertificate } from "./tls"
|
2
|
+
import { z } from "@highstate/contract"
|
3
|
+
import { type common, network } from "@highstate/library"
|
4
|
+
import {
|
5
|
+
ComponentResource,
|
6
|
+
type ComponentResourceOptions,
|
7
|
+
type Input,
|
8
|
+
type Output,
|
9
|
+
output,
|
10
|
+
Resource,
|
11
|
+
} from "@highstate/pulumi"
|
12
|
+
import { ImplementationMediator } from "./impl-ref"
|
13
|
+
|
14
|
+
export const gatewayRouteMediator = new ImplementationMediator(
|
15
|
+
"gateway-route",
|
16
|
+
z.object({
|
17
|
+
name: z.string(),
|
18
|
+
spec: z.custom<GatewayRouteSpec>(),
|
19
|
+
opts: z.custom<ComponentResourceOptions>().optional(),
|
20
|
+
}),
|
21
|
+
z.object({
|
22
|
+
resource: z.instanceof(Resource),
|
23
|
+
endpoints: network.l3EndpointEntity.schema.array(),
|
24
|
+
}),
|
25
|
+
)
|
26
|
+
|
27
|
+
export type GatewayRouteSpec = {
|
28
|
+
/**
|
29
|
+
* The FQDN to expose the workload on.
|
30
|
+
*/
|
31
|
+
fqdn?: Input<string>
|
32
|
+
|
33
|
+
/**
|
34
|
+
* The endpoints of the backend workload to route traffic to.
|
35
|
+
*/
|
36
|
+
endpoints: Input<network.L4Endpoint[]>
|
37
|
+
|
38
|
+
/**
|
39
|
+
* The native data to pass to the implementation.
|
40
|
+
*
|
41
|
+
* This is used for data which implementation may natively understand,
|
42
|
+
* such as Kubernetes `Service` resources.
|
43
|
+
*
|
44
|
+
* Implementations may use this data to create more efficient routes
|
45
|
+
* using native resources.
|
46
|
+
*/
|
47
|
+
nativeData?: Input<unknown>
|
48
|
+
|
49
|
+
/**
|
50
|
+
* The TLS certificate to use for the route.
|
51
|
+
*/
|
52
|
+
tlsCertificate?: Input<TlsCertificate | undefined>
|
53
|
+
} & (
|
54
|
+
| {
|
55
|
+
type: "http"
|
56
|
+
|
57
|
+
/**
|
58
|
+
* Whether to expose the workload over plain HTTP.
|
59
|
+
*
|
60
|
+
* By default, the workload will be exposed over HTTPS.
|
61
|
+
*/
|
62
|
+
insecure?: Input<boolean>
|
63
|
+
|
64
|
+
/**
|
65
|
+
* The relative path to expose the workload on.
|
66
|
+
*
|
67
|
+
* By default, the workload will be exposed on the root path (`/`).
|
68
|
+
*/
|
69
|
+
path?: Input<string>
|
70
|
+
}
|
71
|
+
| {
|
72
|
+
type: "l3"
|
73
|
+
|
74
|
+
/**
|
75
|
+
* The ports to request for the workload.
|
76
|
+
*
|
77
|
+
* The order must match the order of the ports in the service.
|
78
|
+
*
|
79
|
+
* If not provided, random ports will be assigned.
|
80
|
+
*/
|
81
|
+
ports?: number
|
82
|
+
}
|
83
|
+
)
|
84
|
+
|
85
|
+
export type GatewayRouteArgs = GatewayRouteSpec & {
|
86
|
+
/**
|
87
|
+
* The gateway to attach the route to.
|
88
|
+
*/
|
89
|
+
gateway: Input<common.Gateway>
|
90
|
+
}
|
91
|
+
|
92
|
+
export class GatewayRoute extends ComponentResource {
|
93
|
+
/**
|
94
|
+
* The underlying resource created by the implementation.
|
95
|
+
*/
|
96
|
+
readonly resource: Output<Resource>
|
97
|
+
|
98
|
+
/**
|
99
|
+
* The endpoints of the gateway which serve this route.
|
100
|
+
*
|
101
|
+
* In most cases, this will be a single endpoint of the gateway shared for all routes.
|
102
|
+
*/
|
103
|
+
readonly endpoints: Output<network.L3Endpoint[]>
|
104
|
+
|
105
|
+
constructor(name: string, args: GatewayRouteArgs, opts?: ComponentResourceOptions) {
|
106
|
+
super("highstate:common:GatewayRoute", name, args, opts)
|
107
|
+
|
108
|
+
const { resource, endpoints } = gatewayRouteMediator.callOutput(output(args.gateway).implRef, {
|
109
|
+
name,
|
110
|
+
spec: args,
|
111
|
+
opts: { ...opts, parent: this },
|
112
|
+
})
|
113
|
+
|
114
|
+
this.resource = resource
|
115
|
+
this.endpoints = endpoints
|
116
|
+
}
|
117
|
+
}
|
@@ -0,0 +1,123 @@
|
|
1
|
+
import type { ImplementationReference } from "@highstate/library"
|
2
|
+
import type { z } from "@highstate/contract"
|
3
|
+
import { type Output, output, toPromise, type Input, type Unwrap } from "@highstate/pulumi"
|
4
|
+
|
5
|
+
/**
|
6
|
+
* The ImplementationMediator is used as a contract between the calling code and the implementation.
|
7
|
+
*
|
8
|
+
* From the calling code perspective, it provides a way to define the input and output schemas for the implementation
|
9
|
+
* and call the implementation with the provided input.
|
10
|
+
*
|
11
|
+
* From the implementation perspective, it provides a way to get zod function with automatic type inference and validation.
|
12
|
+
*/
|
13
|
+
export class ImplementationMediator<
|
14
|
+
TInputSchema extends z.ZodType,
|
15
|
+
TOutputSchema extends z.ZodType,
|
16
|
+
> {
|
17
|
+
constructor(
|
18
|
+
readonly path: string,
|
19
|
+
private readonly inputSchema: TInputSchema,
|
20
|
+
private readonly outputSchema: TOutputSchema,
|
21
|
+
) {}
|
22
|
+
|
23
|
+
implement<TDataSchema extends z.ZodType>(
|
24
|
+
dataSchema: TDataSchema,
|
25
|
+
func: (
|
26
|
+
input: z.infer<TInputSchema>,
|
27
|
+
data: z.infer<TDataSchema>,
|
28
|
+
) => z.infer<TOutputSchema> | Promise<z.infer<TOutputSchema>>,
|
29
|
+
) {
|
30
|
+
return async (
|
31
|
+
input: z.infer<TInputSchema>,
|
32
|
+
data: z.infer<TDataSchema>,
|
33
|
+
): Promise<z.infer<TOutputSchema>> => {
|
34
|
+
const parsedInput = this.inputSchema.safeParse(input)
|
35
|
+
if (!parsedInput.success) {
|
36
|
+
throw new Error(
|
37
|
+
`Invalid input for implementation "${this.path}": ${parsedInput.error.message}`,
|
38
|
+
)
|
39
|
+
}
|
40
|
+
|
41
|
+
const parsedData = dataSchema.safeParse(data)
|
42
|
+
if (!parsedData.success) {
|
43
|
+
throw new Error(
|
44
|
+
`Invalid data for implementation "${this.path}": ${parsedData.error.message}`,
|
45
|
+
)
|
46
|
+
}
|
47
|
+
|
48
|
+
const result = await func(parsedInput.data, parsedData.data)
|
49
|
+
const parsedResult = this.outputSchema.safeParse(result)
|
50
|
+
|
51
|
+
if (!parsedResult.success) {
|
52
|
+
throw new Error(
|
53
|
+
`Invalid output from implementation "${this.path}": ${parsedResult.error.message}`,
|
54
|
+
)
|
55
|
+
}
|
56
|
+
|
57
|
+
return parsedResult.data
|
58
|
+
}
|
59
|
+
}
|
60
|
+
|
61
|
+
async call(
|
62
|
+
implRef: Input<ImplementationReference>,
|
63
|
+
input: Input<z.infer<TInputSchema>>,
|
64
|
+
): Promise<z.infer<TOutputSchema>> {
|
65
|
+
const resolvedImplRef = await toPromise(implRef)
|
66
|
+
const resolvedInput = await toPromise(input)
|
67
|
+
|
68
|
+
const importPath = `${resolvedImplRef.package}/impl/${this.path}`
|
69
|
+
|
70
|
+
let impl: Record<string, unknown>
|
71
|
+
try {
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
73
|
+
impl = await import(importPath)
|
74
|
+
} catch (error) {
|
75
|
+
throw new Error(`Failed to import module "${importPath}" required by implementation.`, {
|
76
|
+
cause: error,
|
77
|
+
})
|
78
|
+
}
|
79
|
+
|
80
|
+
const funcs = Object.entries(impl).filter(value => typeof value[1] === "function") as [
|
81
|
+
string,
|
82
|
+
Function,
|
83
|
+
][]
|
84
|
+
|
85
|
+
if (funcs.length === 0) {
|
86
|
+
throw new Error(`No implementation functions found in module "${importPath}".`)
|
87
|
+
}
|
88
|
+
|
89
|
+
if (funcs.length > 1) {
|
90
|
+
throw new Error(
|
91
|
+
`Multiple implementation functions found in module "${importPath}": ${funcs.map(func => func[0]).join(", ")}. ` +
|
92
|
+
"Ensure only one function is exported.",
|
93
|
+
)
|
94
|
+
}
|
95
|
+
|
96
|
+
const [funcName, implFunc] = funcs[0]
|
97
|
+
|
98
|
+
let result: unknown
|
99
|
+
try {
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
101
|
+
result = await implFunc(resolvedInput, resolvedImplRef.data)
|
102
|
+
} catch (error) {
|
103
|
+
console.error(`Error in implementation function "${funcName}":`, error)
|
104
|
+
throw new Error(`Implementation function "${funcName}" failed`)
|
105
|
+
}
|
106
|
+
|
107
|
+
const parsedResult = this.outputSchema.safeParse(result)
|
108
|
+
if (!parsedResult.success) {
|
109
|
+
throw new Error(
|
110
|
+
`Implementation function "${funcName}" returned invalid result: ${parsedResult.error.message}`,
|
111
|
+
)
|
112
|
+
}
|
113
|
+
|
114
|
+
return parsedResult.data
|
115
|
+
}
|
116
|
+
|
117
|
+
callOutput(
|
118
|
+
implRef: Input<ImplementationReference>,
|
119
|
+
input: Input<z.infer<TInputSchema>>,
|
120
|
+
): Output<Unwrap<z.infer<TOutputSchema>>> {
|
121
|
+
return output(this.call(implRef, input))
|
122
|
+
}
|
123
|
+
}
|