@ossy/deployment-tools 3.0.9 → 3.4.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +156 -118
  3. package/jest.config.js +5 -0
  4. package/package.json +6 -4
  5. package/src/config/platform-config.js +16 -16
  6. package/src/ecs/platform-ecs-services.js +209 -0
  7. package/src/ecs/platform-ecs-services.spec.js +163 -0
  8. package/src/edge/platform-edge-domains.js +101 -0
  9. package/src/edge/platform-edge-domains.spec.js +87 -0
  10. package/src/index.js +45 -1
  11. package/src/infrastructure/dns-stack.js +47 -3
  12. package/src/infrastructure/dns-stack.spec.js +15 -0
  13. package/src/infrastructure/platform-ci-stack.js +151 -0
  14. package/src/infrastructure/platform-ecs-stack.js +349 -0
  15. package/src/infrastructure/platform-edge-stack.js +213 -0
  16. package/src/infrastructure/platform-secrets-stack.js +103 -0
  17. package/src/infrastructure/platform-stage.js +32 -5
  18. package/src/infrastructure/storage-static-stack.js +4 -5
  19. package/src/secrets/platform-secret-services.js +32 -0
  20. package/src/secrets/platform-secret-services.spec.js +40 -0
  21. package/src/secrets/sync-platform-secrets.js +136 -0
  22. package/src/services/platform-services.js +94 -0
  23. package/src/services/platform-services.spec.js +80 -0
  24. package/src/template/platform-template.js +5 -5
  25. package/src/infrastructure/container-deployment-target/aws-profile.js +0 -25
  26. package/src/infrastructure/container-deployment-target/caddy.service.js +0 -117
  27. package/src/infrastructure/container-deployment-target/container-deployment-target.js +0 -222
  28. package/src/infrastructure/container-deployment-target/container-service.js +0 -162
  29. package/src/infrastructure/container-deployment-target/index.js +0 -3
  30. package/src/infrastructure/container-deployment-target/ossy-api.service.js +0 -54
  31. package/src/infrastructure/container-deployment-target/ossy-runtime.service.js +0 -49
  32. package/src/infrastructure/container-deployment-target/user-data-commands.js +0 -31
  33. package/src/infrastructure/deployment-target-stack.js +0 -53
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared platform service naming and listing from platforms.json.
3
+ *
4
+ * Used by Secrets Manager and ECS so neither domain depends on the other.
5
+ * Built-in `ossy-api` is omitted — API lives in website images; only
6
+ * platform-runtime + configured HTTP `services[]` are included.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} PlatformService
11
+ * @property {string} key - short key (`runtime`, `website-ossy`, …)
12
+ * @property {string} secretName - `{platform}/{key}`
13
+ * @property {string} source - origin label (`platform-runtime` or systemd/service name)
14
+ * @property {object=} entry - original HTTP `services[]` entry when applicable
15
+ */
16
+
17
+ /**
18
+ * Derive the canonical service key from a platforms.json HTTP service name.
19
+ * Strips a leading `ossy-` prefix so `ossy-website-ossy` → `website-ossy`.
20
+ *
21
+ * @param {string} serviceName
22
+ * @returns {string}
23
+ */
24
+ function serviceKeyFromName(serviceName) {
25
+ if (!serviceName || typeof serviceName !== 'string') {
26
+ throw new Error('[platform-services] service name is required')
27
+ }
28
+ return serviceName.replace(/^ossy-/, '')
29
+ }
30
+
31
+ /**
32
+ * Secrets Manager / shared resource name for a service key.
33
+ *
34
+ * @param {string} platformName
35
+ * @param {string} key
36
+ * @returns {string}
37
+ */
38
+ function secretNameFor(platformName, key) {
39
+ return `${platformName}/${key}`
40
+ }
41
+
42
+ /**
43
+ * HTTP `services[]` entries (skips tcp/udp and nameless rows).
44
+ *
45
+ * @param {{ services?: Array<{ name?: string, type?: string }> }} config
46
+ * @returns {Array<{ name: string, type?: string, domain?: string, hosts?: string[], image?: string }>}
47
+ */
48
+ function listHttpServiceEntries(config) {
49
+ return (config.services || []).filter(
50
+ entry => (entry.type || 'http') === 'http' && entry.name
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Platform services that get secrets / ECS targets: runtime + HTTP services[].
56
+ * Skips legacy `ossy-api` (key `api`).
57
+ *
58
+ * @param {{ platformName: string, services?: Array<{ name: string, type?: string }> }} config
59
+ * @returns {PlatformService[]}
60
+ */
61
+ function listPlatformServices(config) {
62
+ if (!config?.platformName) {
63
+ throw new Error('[platform-services] platformName is required')
64
+ }
65
+
66
+ const { platformName } = config
67
+ const runtime = {
68
+ key: 'runtime',
69
+ secretName: secretNameFor(platformName, 'runtime'),
70
+ source: 'platform-runtime',
71
+ }
72
+
73
+ const httpServices = listHttpServiceEntries(config)
74
+ .map(entry => {
75
+ const key = serviceKeyFromName(entry.name)
76
+ return { key, entry }
77
+ })
78
+ .filter(({ key }) => key !== 'api')
79
+ .map(({ key, entry }) => ({
80
+ key,
81
+ secretName: secretNameFor(platformName, key),
82
+ source: entry.name,
83
+ entry,
84
+ }))
85
+
86
+ return [runtime, ...httpServices]
87
+ }
88
+
89
+ module.exports = {
90
+ serviceKeyFromName,
91
+ secretNameFor,
92
+ listHttpServiceEntries,
93
+ listPlatformServices,
94
+ }
@@ -0,0 +1,80 @@
1
+ const { describe, expect, it } = require('@jest/globals')
2
+ const {
3
+ serviceKeyFromName,
4
+ secretNameFor,
5
+ listHttpServiceEntries,
6
+ listPlatformServices,
7
+ } = require('./platform-services')
8
+
9
+ describe('serviceKeyFromName', () => {
10
+ it('strips leading ossy- prefix', () => {
11
+ expect(serviceKeyFromName('ossy-website-ossy')).toBe('website-ossy')
12
+ })
13
+
14
+ it('leaves names without ossy- prefix unchanged', () => {
15
+ expect(serviceKeyFromName('website-ossy')).toBe('website-ossy')
16
+ })
17
+
18
+ it('throws when name is missing', () => {
19
+ expect(() => serviceKeyFromName('')).toThrow(/service name is required/)
20
+ })
21
+ })
22
+
23
+ describe('secretNameFor', () => {
24
+ it('joins platform and key', () => {
25
+ expect(secretNameFor('ossybot', 'website-ossy')).toBe('ossybot/website-ossy')
26
+ })
27
+ })
28
+
29
+ describe('listHttpServiceEntries', () => {
30
+ it('keeps http entries and skips tcp', () => {
31
+ const entries = listHttpServiceEntries({
32
+ services: [
33
+ { name: 'ossy-website-ossy', domain: 'ossy.se' },
34
+ { name: 'minecraft', type: 'tcp', ports: [25565] },
35
+ ],
36
+ })
37
+ expect(entries.map(e => e.name)).toEqual(['ossy-website-ossy'])
38
+ })
39
+ })
40
+
41
+ describe('listPlatformServices', () => {
42
+ it('includes runtime and HTTP services, skips tcp and ossy-api', () => {
43
+ const services = listPlatformServices({
44
+ platformName: 'ossybot',
45
+ services: [
46
+ {
47
+ name: 'ossy-website-ossy',
48
+ domain: 'ossy.se',
49
+ image: 'ghcr.io/ossy-se/website-ossy:latest',
50
+ },
51
+ {
52
+ name: 'ossy-api',
53
+ domain: 'api.ossy.se',
54
+ image: 'ghcr.io/ossy-se/ossy-api:latest',
55
+ },
56
+ {
57
+ name: 'minecraft',
58
+ type: 'tcp',
59
+ image: 'itzg/minecraft-server',
60
+ ports: [25565],
61
+ },
62
+ ],
63
+ })
64
+
65
+ expect(services.map(s => s.secretName)).toEqual([
66
+ 'ossybot/runtime',
67
+ 'ossybot/website-ossy',
68
+ ])
69
+ expect(services.find(s => s.key === 'runtime').source).toBe('platform-runtime')
70
+ expect(services.find(s => s.key === 'website-ossy').source).toBe('ossy-website-ossy')
71
+ expect(services.find(s => s.key === 'website-ossy').entry.image).toBe(
72
+ 'ghcr.io/ossy-se/website-ossy:latest'
73
+ )
74
+ expect(services.some(s => s.key === 'api' || s.secretName.endsWith('/api'))).toBe(false)
75
+ })
76
+
77
+ it('requires platformName', () => {
78
+ expect(() => listPlatformServices({})).toThrow(/platformName is required/)
79
+ })
80
+ })
@@ -3,14 +3,14 @@ const { readFileSync } = require('fs')
3
3
  const { logError, logInfo } = require('../log')
4
4
 
5
5
  /**
6
- * Platform template definition
6
+ * Platform template definition (raw entry from platforms.json before PlatformConfig defaults).
7
7
  * @typedef {Object} PlatformTemplate
8
8
  * @property {string} platformName - Name of platform
9
- *
10
9
  * @property {string} awsAccountId - Aws account id
11
- * @property {string=} awsRegion - ?
12
- * @property {string=} awsKeyPairName - ?
13
- * @property {string=} awsDeploymentSqsArn - ?
10
+ * @property {string=} awsRegion - AWS region (defaults applied in PlatformConfigService)
11
+ * @property {string[]=} githubDeployRepos - repos trusted by platform-ci OIDC (#559)
12
+ * @property {string[]=} domains - Route53 → CloudFront known domains (#560)
13
+ * @property {string=} awsKeyPairName - unused after EC2 decommission (#560)
14
14
  */
15
15
 
16
16
  /**
@@ -1,25 +0,0 @@
1
- /**
2
- * @class
3
- */
4
- class AwsProfile {
5
-
6
- static writeFile(roleArn, region) {
7
-
8
- const awsProfileFile = `
9
- [profile ci-client]
10
- role_arn = ${roleArn}
11
- credential_source = Ec2InstanceMetadata
12
- region = ${region}
13
- `
14
- return [
15
- 'sudo mkdir /home/caddy',
16
- 'sudo mkdir /home/caddy/.aws',
17
- `sudo echo "${awsProfileFile}" >> /home/caddy/.aws/credentials`
18
- ]
19
- }
20
-
21
- }
22
-
23
- module.exports = {
24
- AwsProfile
25
- }
@@ -1,117 +0,0 @@
1
- /**
2
- * Builds the full Caddyfile content.
3
- * - Global block: on-demand TLS pointing at the API ask endpoint
4
- * - API block: explicit Route53 TLS for api.ossy.se
5
- * - One block per ContainerService (from platforms.json `services`)
6
- * - Catch-all :443 block: on-demand TLS, routes to the platform runtime
7
- *
8
- * @param {import('./container-service').ContainerService[]} services
9
- */
10
- const buildCaddyfile = (services) => {
11
- const serviceBlocks = services.map(s => s.caddyBlock).join('\n')
12
-
13
- return `
14
- {
15
- on_demand_tls {
16
- ask http://localhost:3001/api/v0/apps/ask
17
- }
18
- }
19
-
20
- api.ossy.se {
21
- tls {
22
- dns route53 {
23
- max_retries 10
24
- }
25
- }
26
- reverse_proxy localhost:3001
27
- }
28
- ${serviceBlocks}
29
-
30
- :443 {
31
- tls {
32
- on_demand
33
- dns route53 {
34
- max_retries 10
35
- }
36
- }
37
- reverse_proxy localhost:3000
38
- }
39
- `
40
- }
41
-
42
- const systemdServiceFile = `
43
- # caddy-route53.service — Caddy with Route53 DNS plugin for on-demand TLS.
44
- # Configured via /etc/caddy/Caddyfile.
45
-
46
- [Unit]
47
- Description=Caddy
48
- Documentation=https://caddyserver.com/docs/
49
- After=network.target network-online.target
50
- Requires=network-online.target
51
-
52
- [Service]
53
- Type=notify
54
- User=caddy
55
- Group=caddy
56
- ExecStart=/usr/bin/caddy.route53 run --config /etc/caddy/Caddyfile
57
- TimeoutStopSec=5s
58
- LimitNOFILE=1048576
59
- LimitNPROC=512
60
- PrivateTmp=true
61
- ProtectSystem=full
62
- AmbientCapabilities=CAP_NET_BIND_SERVICE
63
-
64
- [Install]
65
- WantedBy=multi-user.target cloud-init.target
66
- `
67
-
68
- /**
69
- * @class
70
- */
71
- class CaddyService {
72
-
73
- /**
74
- * @param {import('./container-service').ContainerService[]} services
75
- */
76
- static install(services = []) {
77
- const caddyfile = buildCaddyfile(services)
78
- return [
79
- // Write Caddyfile
80
- 'sudo mkdir -p /etc/caddy',
81
- `sudo tee /etc/caddy/Caddyfile > /dev/null << 'CADDYEOF'\n${caddyfile}\nCADDYEOF`,
82
- // Write systemd unit
83
- `sudo tee /etc/systemd/system/caddy-route53.service > /dev/null << 'UNITEOF'\n${systemdServiceFile}\nUNITEOF`,
84
- // Create caddy system user (apt install caddy may not run on all Ubuntu versions)
85
- 'sudo useradd --system --home /var/lib/caddy --shell /usr/sbin/nologin --create-home caddy || true',
86
- 'sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https',
87
- 'sudo curl -1sLf \'https://dl.cloudsmith.io/public/caddy/stable/gpg.key\' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg',
88
- 'sudo curl -1sLf \'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt\' | sudo tee /etc/apt/sources.list.d/caddy-stable.list',
89
- 'sudo curl -1sLf \'https://dl.cloudsmith.io/public/caddy/xcaddy/gpg.key\' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-xcaddy-archive-keyring.gpg',
90
- 'sudo curl -1sLf \'https://dl.cloudsmith.io/public/caddy/xcaddy/debian.deb.txt\' | sudo tee /etc/apt/sources.list.d/caddy-xcaddy.list',
91
- 'sudo add-apt-repository ppa:longsleep/golang-backports -y',
92
- 'sudo apt update',
93
- 'sudo apt install caddy xcaddy golang-go -y',
94
- 'sudo xcaddy build --with github.com/caddy-dns/route53',
95
- 'sudo mv ./caddy /usr/bin/caddy.route53'
96
- ]
97
- }
98
-
99
- static enable() {
100
- return [
101
- 'sudo systemctl disable caddy.service',
102
- 'sudo systemctl enable caddy-route53.service'
103
- ]
104
- }
105
-
106
- static start() {
107
- return [
108
- 'sudo systemctl stop caddy.service',
109
- 'sudo systemctl start caddy-route53.service'
110
- ]
111
- }
112
-
113
- }
114
-
115
- module.exports = {
116
- CaddyService
117
- }
@@ -1,222 +0,0 @@
1
- const { Construct } = require('constructs')
2
- const {
3
- Instance,
4
- InstanceType,
5
- InstanceClass,
6
- InstanceSize,
7
- GenericLinuxImage,
8
- Vpc,
9
- SecurityGroup,
10
- Peer,
11
- Port,
12
- UserData,
13
- KeyPair,
14
- BlockDeviceVolume,
15
- CfnEIP,
16
- CfnEIPAssociation
17
- } = require('aws-cdk-lib/aws-ec2')
18
- const { Role, ServicePrincipal, Policy, PolicyStatement, Effect } = require('aws-cdk-lib/aws-iam')
19
- const { Source, BucketDeployment } = require('aws-cdk-lib/aws-s3-deployment')
20
- const { getInstallNodeJs, getInstallNpm, getInstallDocker } = require('./user-data-commands')
21
- const { CaddyService } = require('./caddy.service')
22
- const { OssyRuntimeService } = require('./ossy-runtime.service')
23
- const { OssyApiService } = require('./ossy-api.service')
24
- const { fromConfig: buildContainerServices } = require('./container-service')
25
- const { SupportedRegions } = require('../../config')
26
-
27
- /**
28
- * ContainerServerProps
29
- * @namespace ContainerServer
30
- * @typedef {Object} ContainerServerProps
31
- * @property {PlatformConfig} config - platform config
32
- * @property {Bucket} bucket - s3 bucket
33
- */
34
-
35
- const InstanceImages = {
36
- UBUNTU: 'ami-067bcf851477ebb78' // Ubuntu Server 24.04 LTS (HVM), eu-north-1
37
- }
38
-
39
- /**
40
- * @class
41
- */
42
- class ContainerDeploymentTarget extends Construct {
43
-
44
- /**
45
- * @param {object} scope - scope
46
- * @param {string} id - id
47
- * @param {ContainerServerProps} props - ContainerServerProps
48
- */
49
- constructor(scope, id, props) {
50
- super(scope, id)
51
-
52
- const vpc = Vpc.fromLookup(this, 'VPC', { isDefault: true })
53
- const securityGroup = new SecurityGroup(this, 'SecurityGroup', { vpc, allowAllOutbound: true })
54
-
55
- securityGroup.addIngressRule(
56
- Peer.anyIpv4(),
57
- Port.tcp(22),
58
- 'allow SSH access from anywhere'
59
- )
60
-
61
- securityGroup.addIngressRule(
62
- Peer.anyIpv4(),
63
- Port.tcp(80),
64
- 'allow HTTP traffic from anywhere'
65
- )
66
-
67
- securityGroup.addIngressRule(
68
- Peer.anyIpv4(),
69
- Port.tcp(443),
70
- 'allow HTTPS traffic from anywhere'
71
- )
72
-
73
- // Build container services from config — opens additional security group ports for TCP/UDP services
74
- const containerServices = buildContainerServices(props.config.services)
75
-
76
- containerServices.forEach(service => {
77
- service.securityGroupPorts.forEach(({ port, protocol }) => {
78
- const portRule = protocol === 'udp' ? Port.udp(port) : Port.tcp(port)
79
- securityGroup.addIngressRule(Peer.anyIpv4(), portRule, `${service.name} ${protocol.toUpperCase()} ${port}`)
80
- })
81
- })
82
-
83
- const platformConfigDeployment = new BucketDeployment(this, 'PlatformConfigDeployment', {
84
- sources: [Source.jsonData('platform-config.json', { ...props.config, env: undefined })],
85
- destinationBucket: props.bucket,
86
- // Default prune:true would delete every other object (e.g. media/*) on each infra deploy.
87
- prune: false
88
- })
89
-
90
- const role = new Role(this, 'role', {
91
- assumedBy: new ServicePrincipal('ec2.amazonaws.com')
92
- })
93
-
94
- role.attachInlinePolicy(new Policy(this, 'policy', {
95
- statements: [
96
- new PolicyStatement({
97
- effect: Effect.ALLOW,
98
- actions: [
99
- 'route53:ListResourceRecordSets',
100
- 'route53:GetChange',
101
- 'route53:ChangeResourceRecordSets'
102
- ],
103
- resources: [
104
- `arn:aws:route53:::hostedzone/*`,
105
- 'arn:aws:route53:::change/*'
106
- ]
107
- }),
108
- new PolicyStatement({
109
- effect: Effect.ALLOW,
110
- actions: [
111
- 'route53:ListHostedZonesByName',
112
- 'route53:ListHostedZones'
113
- ],
114
- resources: ['*']
115
- }),
116
- ]
117
- }))
118
-
119
- const userData = UserData.forLinux()
120
-
121
- // Write platform env vars to /etc/environment so all systemd services pick them up.
122
- const envLines = Object.entries(props.config.env ?? {}).map(([k, v]) => `${k}=${v}`)
123
-
124
- userData.addCommands(
125
- 'sudo groupadd docker',
126
- 'sudo usermod -aG docker ubuntu',
127
- 'newgrp docker',
128
- 'sudo apt update -y',
129
- ...getInstallNodeJs(),
130
- ...getInstallNpm(),
131
- ...getInstallDocker(),
132
- 'sudo apt-get install awscli --yes',
133
- // Write all platform env vars before starting any service
134
- `sudo tee /etc/environment << 'ENVEOF'\n${envLines.join('\n')}\nENVEOF`,
135
- // Create shared Docker network for inter-container communication
136
- 'docker network create ossy-network || true',
137
- ...CaddyService.install(containerServices.filter(s => s.caddyBlock !== null)),
138
- ...OssyRuntimeService.install(),
139
- ...OssyApiService.install(),
140
- ...containerServices.flatMap(s => s.install())
141
- )
142
-
143
- userData.addS3DownloadCommand({
144
- bucket: props.bucket,
145
- bucketKey: 'platform-config.json',
146
- localFile: '/home/ubuntu/platform-config.json'
147
- })
148
-
149
- userData.addCommands(
150
- 'sudo systemctl daemon-reload',
151
- ...CaddyService.enable(),
152
- ...CaddyService.start(),
153
- ...OssyRuntimeService.enable(),
154
- ...OssyRuntimeService.start(),
155
- ...OssyApiService.enable(),
156
- ...OssyApiService.start(),
157
- ...containerServices.flatMap(s => [...s.enable(), ...s.start()])
158
- )
159
-
160
- const instanceClassMap = {
161
- t2: InstanceClass.T2,
162
- t3: InstanceClass.T3,
163
- t3a: InstanceClass.T3A,
164
- t4g: InstanceClass.T4G,
165
- m5: InstanceClass.M5,
166
- m6i: InstanceClass.M6I,
167
- c5: InstanceClass.C5,
168
- c6i: InstanceClass.C6I,
169
- }
170
-
171
- const instanceSizeMap = {
172
- nano: InstanceSize.NANO,
173
- micro: InstanceSize.MICRO,
174
- small: InstanceSize.SMALL,
175
- medium: InstanceSize.MEDIUM,
176
- large: InstanceSize.LARGE,
177
- xlarge: InstanceSize.XLARGE,
178
- '2xlarge': InstanceSize.XLARGE2,
179
- }
180
-
181
- const instanceClass = instanceClassMap[props.config.awsInstanceClass ?? 't3']
182
- const instanceSize = instanceSizeMap[props.config.awsInstanceSize ?? 'small']
183
-
184
- if (!instanceClass) throw new Error(`[ContainerDeploymentTarget] Unknown awsInstanceClass: ${props.config.awsInstanceClass}`)
185
- if (!instanceSize) throw new Error(`[ContainerDeploymentTarget] Unknown awsInstanceSize: ${props.config.awsInstanceSize}`)
186
-
187
- const ec2Instance = new Instance(this, 'Ec2Instance', {
188
- vpc,
189
- securityGroup,
190
- userData,
191
- role,
192
- instanceType: InstanceType.of(instanceClass, instanceSize),
193
- machineImage: new GenericLinuxImage({
194
- [SupportedRegions.North]: InstanceImages.UBUNTU
195
- }),
196
- blockDevices: [
197
- {
198
- deviceName: '/dev/sda1',
199
- volume: BlockDeviceVolume.ebs(50)
200
- }
201
- ],
202
- keyPair: KeyPair.fromKeyPairName(this, 'KeyPair', props.config.awsKeyPairName)
203
- })
204
-
205
- props.bucket.grantRead(ec2Instance, '*')
206
-
207
- // Elastic IP gives the instance a stable public IP that survives instance replacements,
208
- // preventing CloudFormation cross-stack export conflicts when the EC2 instance is updated.
209
- const eip = new CfnEIP(this, 'ElasticIp', { domain: 'vpc' })
210
- new CfnEIPAssociation(this, 'EipAssociation', {
211
- instanceId: ec2Instance.instanceId,
212
- allocationId: eip.attrAllocationId
213
- })
214
-
215
- this.instancePublicIp = eip.attrPublicIp
216
-
217
- }
218
- }
219
-
220
- module.exports = {
221
- ContainerDeploymentTarget
222
- }