@ossy/deployment-tools 1.19.0 → 1.20.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/CHANGELOG.md +11 -0
- package/README.md +101 -34
- package/package.json +2 -6
- package/src/config/platform-config.js +5 -4
- package/src/infrastructure/cli.js +2 -18
- package/src/infrastructure/container-deployment-target/caddy.service.js +20 -28
- package/src/infrastructure/container-deployment-target/container-deployment-target.js +48 -16
- package/src/infrastructure/container-deployment-target/container-service.js +101 -0
- package/src/infrastructure/deployment-target-stack.js +29 -76
- package/src/infrastructure/platform-stage.js +40 -0
- package/src/infrastructure/storage-static-stack.js +87 -0
- package/src/template/platform-template.js +0 -3
- package/src/aws-credentials/aws-credentials.js +0 -87
- package/src/aws-credentials/cli.js +0 -55
- package/src/aws-credentials/index.js +0 -1
- package/src/index.cli.js +0 -11
- package/src/infrastructure/container-deployment-target/ossy-website-ossy.service.js +0 -52
- package/src/infrastructure/container-deployment-target/ossy-website-plexus-sanitas.service.js +0 -52
- package/src/infrastructure/trust-ci-stack.js +0 -76
- package/src/template/cli.js +0 -24
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 1.20.0 (2026-05-25)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **deployment-tools:** Refactor container service management and update documentation ([8e2bc9c](https://github.com/ossy-se/ossy/commit/8e2bc9c186dbc9a16ae6127fad755baffc07d0be))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
# 1.19.0 (2026-05-25)
|
|
7
18
|
|
|
8
19
|
|
package/README.md
CHANGED
|
@@ -21,38 +21,111 @@ Caddy uses **on-demand TLS**: before issuing a certificate for any domain, it ca
|
|
|
21
21
|
|
|
22
22
|
All services run as Docker containers managed by systemd. Service files are written to the EC2 host by CDK user data at instance creation.
|
|
23
23
|
|
|
24
|
-
| Service | Unit file | Port |
|
|
25
|
-
|
|
26
|
-
| Caddy | `caddy-route53.service` | 80, 443 |
|
|
27
|
-
| Ossy API | `ossy-api.service` | 3001 |
|
|
28
|
-
| Ossy Platform | `ossy-runtime.service` | 3000 |
|
|
29
|
-
|
|
|
30
|
-
| website-plexus-sanitas | `ossy-website-plexus-sanitas.service` | 3003 | Yes | `ghcr.io/ossy-se/website-plexus-sanitas:latest` |
|
|
24
|
+
| Service | Unit file | Port | Image |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| Caddy | `caddy-route53.service` | 80, 443 | — |
|
|
27
|
+
| Ossy API | `ossy-api.service` | 3001 | `ghcr.io/ossy-se/api:latest` |
|
|
28
|
+
| Ossy Platform | `ossy-runtime.service` | 3000 | `ghcr.io/ossy-se/platform:latest` |
|
|
29
|
+
| _configured via `services`_ | `<name>.service` | _per config_ | _per config_ |
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
> **TODO:** Pin `@ossy/platform` to a specific version at CDK deploy time (thread through `PlatformConfig`) rather than always pulling `@latest`.
|
|
31
|
+
Additional services are generated automatically from the `services` array in `platforms.json` — see the Configuration section below.
|
|
35
32
|
|
|
36
33
|
## Configuration — `platforms.json`
|
|
37
34
|
|
|
38
|
-
`packages/infrastructure/platforms.json` is the single source of truth for a platform.
|
|
35
|
+
`packages/infrastructure/platforms.json` is the single source of truth for a platform. Each entry becomes a CDK `Stage` with its own set of stacks.
|
|
36
|
+
|
|
37
|
+
| Field | Required | Description |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `platformName` | Yes | Unique name, used as the CDK stage name and CloudFormation stack prefix |
|
|
40
|
+
| `awsAccountId` | Yes | AWS account ID |
|
|
41
|
+
| `awsKeyPairName` | Yes | EC2 key pair name for SSH access |
|
|
42
|
+
| `awsInstanceClass` | No | EC2 instance class (e.g. `t3`, `t4g`, `m5`). Defaults to `t3` |
|
|
43
|
+
| `awsInstanceSize` | No | EC2 instance size (e.g. `small`, `medium`, `large`). Defaults to `small` |
|
|
44
|
+
| `sesDomains` | No | Domains to configure SES email identity for |
|
|
45
|
+
| `domains` | No | Domains to create Route53 A records for, pointing to the EC2 Elastic IP. Supports wildcards (e.g. `*.ossy.se`). |
|
|
46
|
+
| `services` | No | Per-platform container services (see below) |
|
|
47
|
+
| `dnsRecords` | No | Additional DNS records by root domain (currently supports MX) |
|
|
48
|
+
| `env` | No | Environment variables written to `/etc/environment` on the EC2 host at boot. **Never uploaded to S3.** |
|
|
49
|
+
|
|
50
|
+
### `services` — container/domain mapping
|
|
51
|
+
|
|
52
|
+
Each entry in `services` generates a systemd unit that pulls and runs a Docker image, and a Caddy block that routes a domain to it. All variables from `env` are forwarded to the container via `--env-file /etc/environment`.
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
"services": [
|
|
56
|
+
{
|
|
57
|
+
"name": "ossy-website-ossy",
|
|
58
|
+
"domain": "ossy.se",
|
|
59
|
+
"image": "ghcr.io/ossy-se/website-ossy:latest"
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Field | Description |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `name` | Unique service name. Used as the Docker container name and systemd unit name (`<name>.service`). |
|
|
67
|
+
| `domain` | Domain this service handles. Gets its own Caddy block with Route53 TLS. |
|
|
68
|
+
| `image` | Docker image to pull and run. |
|
|
69
|
+
|
|
70
|
+
Host ports are auto-assigned starting at `3002` based on the order in the array (`services[0]` → `3002`, `services[1]` → `3003`, etc.). Appending new entries is safe; avoid reordering existing ones.
|
|
71
|
+
|
|
72
|
+
**Fixed built-in services** (always present, not configurable via `services`):
|
|
73
|
+
- `ossy-api` — the Ossy API (`ghcr.io/ossy-se/api:latest`, port 3001)
|
|
74
|
+
- `ossy-runtime` — the platform runtime serving all CMS-published apps (`ghcr.io/ossy-se/platform:latest`, port 3000, catch-all)
|
|
75
|
+
|
|
76
|
+
**Custom domains**: For customer-owned domains (e.g. `woodhill.com`), the customer adds an A record pointing to the Elastic IP at their registrar. No infrastructure change needed — Caddy's on-demand TLS issues the cert automatically once the domain is registered via `ossy app publish`.
|
|
77
|
+
|
|
78
|
+
**Wildcard subdomains**: Add `*.ossy.se` to `domains` for a Route53 wildcard A record. This covers any `*.ossy.se` subdomain without individual records.
|
|
39
79
|
|
|
40
|
-
|
|
41
|
-
- **`domains`** — list of domains to create Route53 A records for, all pointing to the EC2 host IP
|
|
42
|
-
- **`env`** — environment variables written to `/etc/environment` on the EC2 host at boot time. **These are never uploaded to S3.**
|
|
80
|
+
`MEDIA_REPOSITORY` and `MEDIA_CDN_DOMAIN_NAME` are derived automatically from the provisioned S3 bucket and CloudFront distribution — do not set them in `env`.
|
|
43
81
|
|
|
44
|
-
To add
|
|
82
|
+
To add a new platform, add an entry to `platforms.json`. No bucket name or CDN domain is needed; these are auto-generated by CloudFormation.
|
|
45
83
|
|
|
46
|
-
##
|
|
84
|
+
## CDK Stacks
|
|
47
85
|
|
|
48
|
-
|
|
86
|
+
Each platform is a CDK `Stage` containing five stacks:
|
|
87
|
+
|
|
88
|
+
| Stack | Description |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `storage-static` | S3 bucket (auto-named), daily backup plan, CloudFront CDN for `/media`. Kept separate from the EC2 stack so storage resources are never touched during instance updates. |
|
|
91
|
+
| `deployment-target` | EC2 instance with Elastic IP. Imports the S3 bucket from `storage-static` via cross-stack reference. |
|
|
92
|
+
| `dns` | Route53 A records for all domains in `platforms.json`, pointing to the Elastic IP. |
|
|
93
|
+
| `ses` | SES email sending identity and DKIM records. |
|
|
94
|
+
|
|
95
|
+
The Elastic IP ensures the EC2 instance can be replaced (instance type changes, user data updates) without breaking DNS or CloudFormation cross-stack exports.
|
|
96
|
+
|
|
97
|
+
## Bootstrapping a new platform
|
|
49
98
|
|
|
50
99
|
```bash
|
|
51
100
|
cd packages/deployment-tools
|
|
52
|
-
npx cdk deploy ossybot
|
|
101
|
+
npx cdk deploy 'ossybot/**' --profile ossybot --require-approval never
|
|
53
102
|
```
|
|
54
103
|
|
|
55
|
-
|
|
104
|
+
CDK provisions the instance, writes all service files, writes `/etc/environment` from `platforms.json`, and starts all services automatically. No manual SSH steps are required.
|
|
105
|
+
|
|
106
|
+
> Note: EC2 user data only runs on **first boot**. To apply infrastructure changes to an existing instance you must terminate and recreate it, or SSH in and apply changes manually.
|
|
107
|
+
|
|
108
|
+
## Deploying changes
|
|
109
|
+
|
|
110
|
+
Target a single platform:
|
|
111
|
+
```bash
|
|
112
|
+
npx cdk deploy 'ossybot/**' --profile ossybot
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Target all platforms:
|
|
116
|
+
```bash
|
|
117
|
+
npx cdk deploy '**' --profile ossybot
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Preview changes without deploying:
|
|
121
|
+
```bash
|
|
122
|
+
npx cdk diff 'ossybot/**' --profile ossybot
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
List all stacks:
|
|
126
|
+
```bash
|
|
127
|
+
npx cdk list
|
|
128
|
+
```
|
|
56
129
|
|
|
57
130
|
## Redeploying after a code change
|
|
58
131
|
|
|
@@ -74,15 +147,20 @@ sudo systemctl restart ossy-website-plexus-sanitas
|
|
|
74
147
|
|
|
75
148
|
## Environment variables
|
|
76
149
|
|
|
77
|
-
|
|
150
|
+
Defined in the `env` block of `platforms.json`, written to `/etc/environment` at boot. The following variables are injected automatically by CDK and should **not** be set manually:
|
|
151
|
+
|
|
152
|
+
| Variable | Source |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `MEDIA_REPOSITORY` | S3 bucket name from `storage-static` stack |
|
|
155
|
+
| `MEDIA_CDN_DOMAIN_NAME` | CloudFront domain from `storage-static` stack |
|
|
156
|
+
|
|
157
|
+
All other env vars:
|
|
78
158
|
|
|
79
159
|
| Variable | Used by | Description |
|
|
80
160
|
|---|---|---|
|
|
81
161
|
| `DB_URL` | API | Atlas MongoDB connection string |
|
|
82
162
|
| `DB_NAME` | API | MongoDB database name |
|
|
83
163
|
| `TOKEN_SECRET` | API | JWT signing secret |
|
|
84
|
-
| `MEDIA_REPOSITORY` | API | S3 bucket name for media uploads |
|
|
85
|
-
| `MEDIA_CDN_DOMAIN_NAME` | API | CloudFront domain for public media |
|
|
86
164
|
| `AWS_ACCESS_KEY_ID` | API | AWS credentials for S3 |
|
|
87
165
|
| `AWS_SECRET_ACCESS_KEY` | API | AWS credentials for S3 |
|
|
88
166
|
| `OSSY_API_KEY` | Platform, websites | API JWT for CMS reads |
|
|
@@ -90,22 +168,11 @@ All env vars are defined in the `env` block of `platforms.json` and written to `
|
|
|
90
168
|
| `OPENAI_API_KEY` | Worker | OpenAI API key |
|
|
91
169
|
| `OPENAI_ORGANIZATION` | Worker | OpenAI organisation ID |
|
|
92
170
|
|
|
93
|
-
## Infrastructure (CDK)
|
|
94
|
-
|
|
95
|
-
CDK stacks are defined in `src/infrastructure/`. Run via `cdk deploy` from the `deployment-tools` package.
|
|
96
|
-
|
|
97
|
-
| Stack | Description |
|
|
98
|
-
|---|---|
|
|
99
|
-
| `trust-ci` | IAM role for CI to deploy infrastructure |
|
|
100
|
-
| `deployment-target` | EC2 host, S3 bucket, CloudFront CDN for media |
|
|
101
|
-
| `dns` | Route53 A records for all domains in `platforms.json` |
|
|
102
|
-
| `ses` | SES email sending |
|
|
103
|
-
|
|
104
171
|
## Useful commands
|
|
105
172
|
|
|
106
173
|
**SSH into instance**
|
|
107
174
|
```bash
|
|
108
|
-
ssh -i path/to/keys ubuntu@<ip>
|
|
175
|
+
ssh -i path/to/keys ubuntu@<elastic-ip>
|
|
109
176
|
```
|
|
110
177
|
|
|
111
178
|
**View service logs**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/deployment-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Collection of scripts and tools to aid deployment of containers and static files to Amazon Web Services through GitHub Actions",
|
|
5
5
|
"source": "./src/index.js",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,11 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"author": "Ossy",
|
|
14
14
|
"license": "ISC",
|
|
15
|
-
"bin": "./src/index.cli.js",
|
|
16
15
|
"dependencies": {
|
|
17
|
-
"@actions/core": "^1.10.0",
|
|
18
|
-
"@aws-sdk/client-sts": "^3.245.0",
|
|
19
|
-
"arg": "^5.0.2",
|
|
20
16
|
"aws-cdk": "^2.73.0",
|
|
21
17
|
"aws-cdk-lib": "^2.73.0",
|
|
22
18
|
"constructs": "^10.1.304",
|
|
@@ -26,5 +22,5 @@
|
|
|
26
22
|
"devDependencies": {
|
|
27
23
|
"jest": "^27.5.1"
|
|
28
24
|
},
|
|
29
|
-
"gitHead": "
|
|
25
|
+
"gitHead": "50d7e273859b6b77606d8530913b971c7445a34a"
|
|
30
26
|
}
|
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
* @property {string[]=} sesDomains - list of domains to configure AWS SES email identity and DKIM records for
|
|
6
6
|
* @property {string[]=} domains - list of domains to create Route53 A records for, pointing to the EC2 host
|
|
7
7
|
* @property {object} dnsRecords - map of dns records by root domain, only supports MX records so that we can add dns records for our email service
|
|
8
|
+
* @property {Array<{name: string, domain: string, image: string}>=} services - per-platform container services; each entry generates a systemd unit and a Caddy domain block. Host ports are auto-assigned starting at 3002.
|
|
8
9
|
* @property {Record<string,string>=} env - environment variables written to /etc/environment on the EC2 host at boot time; never uploaded to S3
|
|
9
10
|
*
|
|
10
11
|
* @property {string} awsAccountId - Aws account id
|
|
11
|
-
* @property {string=} awsRegion -
|
|
12
|
-
* @property {string=} awsKeyPairName -
|
|
13
|
-
* @property {string}
|
|
12
|
+
* @property {string=} awsRegion - AWS region, defaults to eu-north-1
|
|
13
|
+
* @property {string=} awsKeyPairName - EC2 key pair name for SSH access
|
|
14
|
+
* @property {string=} awsInstanceClass - EC2 instance class (e.g. t3, t4g, m5), defaults to t3
|
|
15
|
+
* @property {string=} awsInstanceSize - EC2 instance size (e.g. small, medium, large), defaults to small
|
|
14
16
|
*
|
|
15
17
|
* @property {string=} ciDockerNetworkName - ?
|
|
16
|
-
* @property {string} ciGithubActionsRepo - organisation/repoName
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
const SupportedRegions = {
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* eslint-disable no-new */
|
|
3
3
|
const { App } = require('aws-cdk-lib')
|
|
4
|
-
const {
|
|
5
|
-
const { DeploymentTargetStack } = require('./deployment-target-stack')
|
|
6
|
-
const { DnsStack } = require('./dns-stack')
|
|
7
|
-
const { SesStack } = require('./ses-stack')
|
|
4
|
+
const { PlatformStage } = require('./platform-stage')
|
|
8
5
|
const { PlatformTemplateService } = require('../template')
|
|
9
6
|
const { PlatformConfigService } = require('../config')
|
|
10
7
|
|
|
@@ -15,25 +12,12 @@ PlatformTemplateService
|
|
|
15
12
|
const app = new App()
|
|
16
13
|
|
|
17
14
|
configs.forEach(config => {
|
|
18
|
-
|
|
19
|
-
const stackProps = {
|
|
15
|
+
new PlatformStage(app, config.platformName, {
|
|
20
16
|
config,
|
|
21
17
|
env: {
|
|
22
18
|
account: config.awsAccountId,
|
|
23
19
|
region: config.awsRegion
|
|
24
20
|
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
new TrustCiStack(app, `${config.platformName}-trust-ci`, stackProps)
|
|
28
|
-
|
|
29
|
-
const deploymentTarget = new DeploymentTargetStack(app, `${config.platformName}-deployment-target`, stackProps)
|
|
30
|
-
|
|
31
|
-
new DnsStack(app, `${config.platformName}-dns`, {
|
|
32
|
-
...stackProps,
|
|
33
|
-
containerDeploymentTargetPublicIp: deploymentTarget.containerDeploymentTargetPublicIp
|
|
34
21
|
})
|
|
35
|
-
|
|
36
|
-
new SesStack(app, `${config.platformName}-ses`, stackProps)
|
|
37
|
-
|
|
38
22
|
})
|
|
39
23
|
})
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
|
|
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 `
|
|
2
14
|
{
|
|
3
15
|
on_demand_tls {
|
|
4
16
|
ask http://localhost:3001/api/v0/apps/ask
|
|
@@ -14,32 +26,7 @@ api.ossy.se {
|
|
|
14
26
|
}
|
|
15
27
|
reverse_proxy localhost:3001
|
|
16
28
|
}
|
|
17
|
-
|
|
18
|
-
# TODO: remove this block once ossy.se traffic flows through the platform runtime.
|
|
19
|
-
# Once ossy app publish is validated for ossy.se, delete this block and the
|
|
20
|
-
# ossy-website-ossy.service so the catch-all below serves it via the platform.
|
|
21
|
-
ossy.se {
|
|
22
|
-
tls {
|
|
23
|
-
dns route53 {
|
|
24
|
-
max_retries 10
|
|
25
|
-
profile ci-client
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
reverse_proxy localhost:3002
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
# TODO: remove this block once www.plexus-sanitas.com traffic flows through the platform runtime.
|
|
32
|
-
# Once ossy app publish is validated for plexus-sanitas, delete this block and the
|
|
33
|
-
# ossy-website-plexus-sanitas.service so the catch-all below serves it via the platform.
|
|
34
|
-
www.plexus-sanitas.com {
|
|
35
|
-
tls {
|
|
36
|
-
dns route53 {
|
|
37
|
-
max_retries 10
|
|
38
|
-
profile ci-client
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
reverse_proxy localhost:3003
|
|
42
|
-
}
|
|
29
|
+
${serviceBlocks}
|
|
43
30
|
|
|
44
31
|
:443 {
|
|
45
32
|
tls {
|
|
@@ -52,6 +39,7 @@ www.plexus-sanitas.com {
|
|
|
52
39
|
reverse_proxy localhost:3000
|
|
53
40
|
}
|
|
54
41
|
`
|
|
42
|
+
}
|
|
55
43
|
|
|
56
44
|
const systemdServiceFile = `
|
|
57
45
|
# caddy-route53.service — Caddy with Route53 DNS plugin for on-demand TLS.
|
|
@@ -84,7 +72,11 @@ WantedBy=multi-user.target cloud-init.target
|
|
|
84
72
|
*/
|
|
85
73
|
class CaddyService {
|
|
86
74
|
|
|
87
|
-
|
|
75
|
+
/**
|
|
76
|
+
* @param {import('./container-service').ContainerService[]} services
|
|
77
|
+
*/
|
|
78
|
+
static install(services = []) {
|
|
79
|
+
const caddyfile = buildCaddyfile(services)
|
|
88
80
|
return [
|
|
89
81
|
// Write Caddyfile
|
|
90
82
|
'sudo mkdir -p /etc/caddy',
|
|
@@ -11,7 +11,9 @@ const {
|
|
|
11
11
|
Port,
|
|
12
12
|
UserData,
|
|
13
13
|
KeyPair,
|
|
14
|
-
BlockDeviceVolume
|
|
14
|
+
BlockDeviceVolume,
|
|
15
|
+
CfnEIP,
|
|
16
|
+
CfnEIPAssociation
|
|
15
17
|
} = require('aws-cdk-lib/aws-ec2')
|
|
16
18
|
const { Role, ServicePrincipal, Policy, PolicyStatement, Effect } = require('aws-cdk-lib/aws-iam')
|
|
17
19
|
const { Source, BucketDeployment } = require('aws-cdk-lib/aws-s3-deployment')
|
|
@@ -19,8 +21,7 @@ const { getInstallNodeJs, getInstallNpm, getInstallDocker } = require('./user-da
|
|
|
19
21
|
const { CaddyService } = require('./caddy.service')
|
|
20
22
|
const { OssyRuntimeService } = require('./ossy-runtime.service')
|
|
21
23
|
const { OssyApiService } = require('./ossy-api.service')
|
|
22
|
-
const {
|
|
23
|
-
const { OssyWebsitePlexusSanitasService } = require('./ossy-website-plexus-sanitas.service')
|
|
24
|
+
const { ContainerService } = require('./container-service')
|
|
24
25
|
const { AwsProfile } = require('./aws-profile')
|
|
25
26
|
const { SupportedRegions } = require('../../config')
|
|
26
27
|
|
|
@@ -71,7 +72,7 @@ class ContainerDeploymentTarget extends Construct {
|
|
|
71
72
|
)
|
|
72
73
|
|
|
73
74
|
const platformConfigDeployment = new BucketDeployment(this, 'PlatformConfigDeployment', {
|
|
74
|
-
sources: [Source.jsonData('platform-config.json', { ...props.config,
|
|
75
|
+
sources: [Source.jsonData('platform-config.json', { ...props.config, env: undefined })],
|
|
75
76
|
destinationBucket: props.bucket,
|
|
76
77
|
// Default prune:true would delete every other object (e.g. media/*) on each infra deploy.
|
|
77
78
|
prune: false
|
|
@@ -111,6 +112,9 @@ class ContainerDeploymentTarget extends Construct {
|
|
|
111
112
|
// Write platform env vars to /etc/environment so all systemd services pick them up.
|
|
112
113
|
const envLines = Object.entries(props.config.env ?? {}).map(([k, v]) => `${k}=${v}`)
|
|
113
114
|
|
|
115
|
+
// Build generic container services from config.services
|
|
116
|
+
const containerServices = ContainerService.fromConfig(props.config.services)
|
|
117
|
+
|
|
114
118
|
userData.addCommands(
|
|
115
119
|
'sudo groupadd docker',
|
|
116
120
|
'sudo usermod -aG docker ubuntu',
|
|
@@ -125,11 +129,10 @@ class ContainerDeploymentTarget extends Construct {
|
|
|
125
129
|
`sudo tee /etc/environment << 'ENVEOF'\n${envLines.join('\n')}\nENVEOF`,
|
|
126
130
|
// Create shared Docker network for inter-container communication
|
|
127
131
|
'docker network create ossy-network || true',
|
|
128
|
-
...CaddyService.install(),
|
|
132
|
+
...CaddyService.install(containerServices),
|
|
129
133
|
...OssyRuntimeService.install(),
|
|
130
134
|
...OssyApiService.install(),
|
|
131
|
-
...
|
|
132
|
-
...OssyWebsitePlexusSanitasService.install()
|
|
135
|
+
...containerServices.flatMap(s => s.install())
|
|
133
136
|
)
|
|
134
137
|
|
|
135
138
|
userData.addS3DownloadCommand({
|
|
@@ -146,21 +149,42 @@ class ContainerDeploymentTarget extends Construct {
|
|
|
146
149
|
...OssyRuntimeService.start(),
|
|
147
150
|
...OssyApiService.enable(),
|
|
148
151
|
...OssyApiService.start(),
|
|
149
|
-
...
|
|
150
|
-
...OssyWebsiteOssyService.start(),
|
|
151
|
-
...OssyWebsitePlexusSanitasService.enable(),
|
|
152
|
-
...OssyWebsitePlexusSanitasService.start()
|
|
152
|
+
...containerServices.flatMap(s => [...s.enable(), ...s.start()])
|
|
153
153
|
)
|
|
154
154
|
|
|
155
|
+
const instanceClassMap = {
|
|
156
|
+
t2: InstanceClass.T2,
|
|
157
|
+
t3: InstanceClass.T3,
|
|
158
|
+
t3a: InstanceClass.T3A,
|
|
159
|
+
t4g: InstanceClass.T4G,
|
|
160
|
+
m5: InstanceClass.M5,
|
|
161
|
+
m6i: InstanceClass.M6I,
|
|
162
|
+
c5: InstanceClass.C5,
|
|
163
|
+
c6i: InstanceClass.C6I,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const instanceSizeMap = {
|
|
167
|
+
nano: InstanceSize.NANO,
|
|
168
|
+
micro: InstanceSize.MICRO,
|
|
169
|
+
small: InstanceSize.SMALL,
|
|
170
|
+
medium: InstanceSize.MEDIUM,
|
|
171
|
+
large: InstanceSize.LARGE,
|
|
172
|
+
xlarge: InstanceSize.XLARGE,
|
|
173
|
+
'2xlarge': InstanceSize.XLARGE2,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const instanceClass = instanceClassMap[props.config.awsInstanceClass ?? 't3']
|
|
177
|
+
const instanceSize = instanceSizeMap[props.config.awsInstanceSize ?? 'small']
|
|
178
|
+
|
|
179
|
+
if (!instanceClass) throw new Error(`[ContainerDeploymentTarget] Unknown awsInstanceClass: ${props.config.awsInstanceClass}`)
|
|
180
|
+
if (!instanceSize) throw new Error(`[ContainerDeploymentTarget] Unknown awsInstanceSize: ${props.config.awsInstanceSize}`)
|
|
181
|
+
|
|
155
182
|
const ec2Instance = new Instance(this, 'Ec2Instance', {
|
|
156
183
|
vpc,
|
|
157
184
|
securityGroup,
|
|
158
185
|
userData,
|
|
159
186
|
role,
|
|
160
|
-
instanceType: InstanceType.of(
|
|
161
|
-
InstanceClass.T3,
|
|
162
|
-
InstanceSize.SMALL
|
|
163
|
-
),
|
|
187
|
+
instanceType: InstanceType.of(instanceClass, instanceSize),
|
|
164
188
|
machineImage: new GenericLinuxImage({
|
|
165
189
|
[SupportedRegions.North]: InstanceImages.UBUNTU
|
|
166
190
|
}),
|
|
@@ -175,7 +199,15 @@ class ContainerDeploymentTarget extends Construct {
|
|
|
175
199
|
|
|
176
200
|
props.bucket.grantRead(ec2Instance, '*')
|
|
177
201
|
|
|
178
|
-
|
|
202
|
+
// Elastic IP gives the instance a stable public IP that survives instance replacements,
|
|
203
|
+
// preventing CloudFormation cross-stack export conflicts when the EC2 instance is updated.
|
|
204
|
+
const eip = new CfnEIP(this, 'ElasticIp', { domain: 'vpc' })
|
|
205
|
+
new CfnEIPAssociation(this, 'EipAssociation', {
|
|
206
|
+
instanceId: ec2Instance.instanceId,
|
|
207
|
+
allocationId: eip.attrAllocationId
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
this.instancePublicIp = eip.attrPublicIp
|
|
179
211
|
|
|
180
212
|
}
|
|
181
213
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContainerServiceConfig
|
|
3
|
+
* @typedef {Object} ContainerServiceConfig
|
|
4
|
+
* @property {string} name - Unique service name, used for the container name and systemd unit
|
|
5
|
+
* @property {string} domain - Domain this service is reachable on (used in Caddy block)
|
|
6
|
+
* @property {string} image - Docker image to run (e.g. ghcr.io/ossy-se/website-ossy:latest)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const BASE_PORT = 3002 // 3000 = platform runtime, 3001 = API
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ContainerService generates a systemd unit file and a Caddy reverse-proxy block
|
|
13
|
+
* for a domain → Docker container mapping defined in platforms.json `services`.
|
|
14
|
+
*
|
|
15
|
+
* Host ports are auto-assigned starting at 3002 based on the service's index in
|
|
16
|
+
* the array. All containers are expected to listen on port 3000 internally.
|
|
17
|
+
*
|
|
18
|
+
* All environment variables from /etc/environment are forwarded to the container
|
|
19
|
+
* via --env-file, so no per-service env wiring is needed.
|
|
20
|
+
*/
|
|
21
|
+
class ContainerService {
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {ContainerServiceConfig} config
|
|
25
|
+
* @param {number} index - position in the services array, used to derive host port
|
|
26
|
+
*/
|
|
27
|
+
constructor({ name, domain, image }, index) {
|
|
28
|
+
this.name = name
|
|
29
|
+
this.domain = domain
|
|
30
|
+
this.image = image
|
|
31
|
+
this.port = BASE_PORT + index
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get systemdUnitFile() {
|
|
35
|
+
return `
|
|
36
|
+
[Unit]
|
|
37
|
+
Description=Container service: ${this.name} (${this.domain})
|
|
38
|
+
After=network.target docker.service
|
|
39
|
+
Requires=docker.service
|
|
40
|
+
|
|
41
|
+
[Service]
|
|
42
|
+
EnvironmentFile=/etc/environment
|
|
43
|
+
ExecStartPre=-/usr/bin/docker rm -f ${this.name}
|
|
44
|
+
ExecStartPre=/usr/bin/docker pull ${this.image}
|
|
45
|
+
ExecStart=/usr/bin/docker run --name ${this.name} \\
|
|
46
|
+
--network ossy-network \\
|
|
47
|
+
-p ${this.port}:3000 \\
|
|
48
|
+
--env-file /etc/environment \\
|
|
49
|
+
${this.image}
|
|
50
|
+
ExecStop=/usr/bin/docker stop ${this.name}
|
|
51
|
+
Restart=on-failure
|
|
52
|
+
RestartSec=5
|
|
53
|
+
|
|
54
|
+
[Install]
|
|
55
|
+
WantedBy=multi-user.target
|
|
56
|
+
`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get caddyBlock() {
|
|
60
|
+
return `
|
|
61
|
+
${this.domain} {
|
|
62
|
+
tls {
|
|
63
|
+
dns route53 {
|
|
64
|
+
max_retries 10
|
|
65
|
+
profile ci-client
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
reverse_proxy localhost:${this.port}
|
|
69
|
+
}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get unitName() {
|
|
73
|
+
return `${this.name}.service`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
install() {
|
|
77
|
+
return [`sudo tee /etc/systemd/system/${this.unitName} > /dev/null << 'EOF'\n${this.systemdUnitFile}\nEOF`]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
enable() {
|
|
81
|
+
return [`sudo systemctl enable ${this.name}`]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
start() {
|
|
85
|
+
return [`sudo systemctl start ${this.name}`]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build ContainerService instances from the `services` array in platforms.json.
|
|
90
|
+
* @param {ContainerServiceConfig[]} services
|
|
91
|
+
* @returns {ContainerService[]}
|
|
92
|
+
*/
|
|
93
|
+
static fromConfig(services = []) {
|
|
94
|
+
return services.map((s, index) => new ContainerService(s, index))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
ContainerService
|
|
101
|
+
}
|
|
@@ -1,97 +1,50 @@
|
|
|
1
1
|
/* eslint-disable no-new */
|
|
2
|
-
const { CfnOutput, Stack
|
|
3
|
-
const { BackupPlan, BackupVault, BackupResource } = require('aws-cdk-lib/aws-backup')
|
|
4
|
-
const { Bucket, BlockPublicAccess } = require('aws-cdk-lib/aws-s3')
|
|
5
|
-
const { Distribution, PriceClass, ResponseHeadersPolicy } = require('aws-cdk-lib/aws-cloudfront')
|
|
6
|
-
const { S3BucketOrigin } = require('aws-cdk-lib/aws-cloudfront-origins')
|
|
2
|
+
const { CfnOutput, Stack } = require('aws-cdk-lib')
|
|
7
3
|
const { ContainerDeploymentTarget } = require('./container-deployment-target')
|
|
8
4
|
|
|
9
5
|
/**
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
* DeploymentTargetStackProps
|
|
7
|
+
* @typedef {Object} DeploymentTargetStackProps
|
|
8
|
+
* @property {PlatformConfig} config - platform config
|
|
9
|
+
* @property {import('aws-cdk-lib/aws-s3').IBucket} bucket - static assets bucket (from StorageStaticStack)
|
|
10
|
+
* @property {string} cdnDomainName - CloudFront domain name (from StorageStaticStack)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* DeploymentTargetStack provisions the EC2 instance (with Elastic IP) that runs the
|
|
15
|
+
* platform services. The S3 bucket lives in StorageStaticStack so that EC2 replacements
|
|
16
|
+
* never trigger CloudFormation cross-stack export conflicts on storage resources.
|
|
17
|
+
*
|
|
18
|
+
* MEDIA_REPOSITORY and MEDIA_CDN_DOMAIN_NAME are derived from the actual bucket and CDN
|
|
19
|
+
* resources rather than being hardcoded in platforms.json.
|
|
20
|
+
*/
|
|
12
21
|
class DeploymentTargetStack extends Stack {
|
|
13
22
|
constructor(scope, id, props) {
|
|
14
23
|
super(scope, id, props)
|
|
15
24
|
|
|
16
25
|
if (!props?.config) {
|
|
17
|
-
throw ('[DeploymentTargetStack] No
|
|
26
|
+
throw ('[DeploymentTargetStack] No config provided')
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
allowedHeaders: ['*'],
|
|
31
|
-
allowedMethods: ['GET', 'PUT', 'POST' ],
|
|
32
|
-
allowedOrigins: [ '*' ],
|
|
33
|
-
exposeHeaders: []
|
|
34
|
-
}]
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
const mediaBackupVault = new BackupVault(this, 'StaticMediaBackupVault', {
|
|
38
|
-
backupVaultName: `${props.config.platformName}-static-media`,
|
|
39
|
-
removalPolicy: RemovalPolicy.RETAIN
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
const mediaBackupPlan = BackupPlan.daily35DayRetention(this, 'StaticMediaBackupPlan', mediaBackupVault)
|
|
43
|
-
|
|
44
|
-
mediaBackupPlan.addSelection('StaticDeploymentTargetBucket', {
|
|
45
|
-
backupSelectionName: `${props.config.platformName}-s3-static-media`,
|
|
46
|
-
resources: [BackupResource.fromArn(staticDeploymentTarget.bucketArn)],
|
|
47
|
-
allowRestores: true
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
const mediaCDN = new Distribution(this, 'Media', {
|
|
51
|
-
defaultBehavior: {
|
|
52
|
-
origin: S3BucketOrigin.withOriginAccessControl(staticDeploymentTarget, { originPath: '/media' }),
|
|
53
|
-
// S3 bucket CORS does not add headers on viewer responses; without this, browser fetch()
|
|
54
|
-
// to the CDN is cross-origin and Chrome may block with net::ERR_BLOCKED_BY_ORB.
|
|
55
|
-
responseHeadersPolicy:
|
|
56
|
-
ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT
|
|
57
|
-
},
|
|
58
|
-
priceClass: PriceClass.PRICE_CLASS_100
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
const containerDeploymentTarget =
|
|
62
|
-
new ContainerDeploymentTarget(this, 'ContainerDeploymentTarget', {
|
|
63
|
-
config: props.config,
|
|
64
|
-
bucket: staticDeploymentTarget
|
|
29
|
+
const containerDeploymentTarget = new ContainerDeploymentTarget(this, 'ContainerDeploymentTarget', {
|
|
30
|
+
config: {
|
|
31
|
+
...props.config,
|
|
32
|
+
env: {
|
|
33
|
+
...props.config.env,
|
|
34
|
+
MEDIA_REPOSITORY: props.bucket.bucketName,
|
|
35
|
+
MEDIA_CDN_DOMAIN_NAME: `https://${props.cdnDomainName}`
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
bucket: props.bucket
|
|
65
39
|
})
|
|
66
40
|
|
|
67
41
|
this.containerDeploymentTargetPublicIp = containerDeploymentTarget.instancePublicIp
|
|
68
|
-
this.staticDeploymentTargetBucketName = staticDeploymentTarget.bucketName
|
|
69
|
-
this.staticDeploymentTargetBucketArn = staticDeploymentTarget.bucketArn
|
|
70
|
-
|
|
71
|
-
new CfnOutput(this, 'MediaCDNDomainName', {
|
|
72
|
-
value: mediaCDN.domainName,
|
|
73
|
-
description: 'Domain name of CDN distribution',
|
|
74
|
-
exportName: 'MediaCDNDomainName'
|
|
75
|
-
})
|
|
76
42
|
|
|
77
43
|
new CfnOutput(this, 'ContainerDeploymentTargetPublicIp', {
|
|
78
44
|
value: containerDeploymentTarget.instancePublicIp,
|
|
79
|
-
description: 'Public
|
|
80
|
-
exportName:
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
new CfnOutput(this, 'StaticDeploymentTargetBucketName', {
|
|
84
|
-
value: staticDeploymentTarget.bucketName,
|
|
85
|
-
description: 'Name of static deployment target bucket',
|
|
86
|
-
exportName: 'awsStaticDeploymentTargetBucketName'
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
new CfnOutput(this, 'StaticDeploymentTargetBucketArn', {
|
|
90
|
-
value: staticDeploymentTarget.bucketArn,
|
|
91
|
-
description: 'Name of static deployment target bucket',
|
|
92
|
-
exportName: 'awsStaticDeploymentTargetBucketArn'
|
|
45
|
+
description: 'Public IP of the EC2 instance (Elastic IP)',
|
|
46
|
+
exportName: `${props.config.platformName}-ContainerDeploymentTargetPublicIp`
|
|
93
47
|
})
|
|
94
|
-
|
|
95
48
|
}
|
|
96
49
|
}
|
|
97
50
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* eslint-disable no-new */
|
|
2
|
+
const { Stage } = require('aws-cdk-lib')
|
|
3
|
+
const { StorageStaticStack } = require('./storage-static-stack')
|
|
4
|
+
const { DeploymentTargetStack } = require('./deployment-target-stack')
|
|
5
|
+
const { DnsStack } = require('./dns-stack')
|
|
6
|
+
const { SesStack } = require('./ses-stack')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PlatformStage groups all stacks that make up a single platform deployment.
|
|
10
|
+
* Using a Stage lets you target one platform at a time:
|
|
11
|
+
*
|
|
12
|
+
* cdk deploy 'ossybot/**' --profile ossybot
|
|
13
|
+
*
|
|
14
|
+
* Stack names within the stage are automatically prefixed with the platform name,
|
|
15
|
+
* e.g. ossybot/trust-ci, ossybot/storage-static, ossybot/deployment-target, etc.
|
|
16
|
+
*/
|
|
17
|
+
class PlatformStage extends Stage {
|
|
18
|
+
constructor(scope, id, props) {
|
|
19
|
+
super(scope, id, props)
|
|
20
|
+
|
|
21
|
+
const storageStatic = new StorageStaticStack(this, 'storage-static', props)
|
|
22
|
+
|
|
23
|
+
const deploymentTarget = new DeploymentTargetStack(this, 'deployment-target', {
|
|
24
|
+
...props,
|
|
25
|
+
bucket: storageStatic.bucket,
|
|
26
|
+
cdnDomainName: storageStatic.cdnDomainName
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
new DnsStack(this, 'dns', {
|
|
30
|
+
...props,
|
|
31
|
+
containerDeploymentTargetPublicIp: deploymentTarget.containerDeploymentTargetPublicIp
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
new SesStack(this, 'ses', props)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
PlatformStage
|
|
40
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* eslint-disable no-new */
|
|
2
|
+
const { CfnOutput, Stack, RemovalPolicy } = require('aws-cdk-lib')
|
|
3
|
+
const { BackupPlan, BackupVault, BackupResource } = require('aws-cdk-lib/aws-backup')
|
|
4
|
+
const { Bucket, BlockPublicAccess } = require('aws-cdk-lib/aws-s3')
|
|
5
|
+
const { Distribution, PriceClass, ResponseHeadersPolicy } = require('aws-cdk-lib/aws-cloudfront')
|
|
6
|
+
const { S3BucketOrigin } = require('aws-cdk-lib/aws-cloudfront-origins')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* StorageStaticStack provisions the S3 bucket used for static assets and media,
|
|
10
|
+
* a CloudFront CDN distribution serving the /media prefix, and a daily backup plan.
|
|
11
|
+
*
|
|
12
|
+
* This stack is intentionally kept separate from the deployment-target stack so that
|
|
13
|
+
* the long-lived storage resources are never touched when the EC2 instance is updated
|
|
14
|
+
* or replaced, avoiding CloudFormation cross-stack export conflicts.
|
|
15
|
+
*
|
|
16
|
+
* Exports (consumed by deployment-target-stack via cross-stack reference):
|
|
17
|
+
* - `bucket` — the S3 Bucket construct
|
|
18
|
+
* - `cdnDomainName` — the CloudFront distribution domain name
|
|
19
|
+
*/
|
|
20
|
+
class StorageStaticStack extends Stack {
|
|
21
|
+
constructor(scope, id, props) {
|
|
22
|
+
super(scope, id, props)
|
|
23
|
+
|
|
24
|
+
if (!props?.config) {
|
|
25
|
+
throw ('[StorageStaticStack] No config provided')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bucket = new Bucket(this, 'StaticDeploymentTarget', {
|
|
29
|
+
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
|
|
30
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
31
|
+
autoDeleteObjects: false,
|
|
32
|
+
versioned: true,
|
|
33
|
+
cors: [{
|
|
34
|
+
allowedHeaders: ['*'],
|
|
35
|
+
allowedMethods: ['GET', 'PUT', 'POST'],
|
|
36
|
+
allowedOrigins: ['*'],
|
|
37
|
+
exposeHeaders: []
|
|
38
|
+
}]
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const backupVault = new BackupVault(this, 'StaticMediaBackupVault', {
|
|
42
|
+
removalPolicy: RemovalPolicy.RETAIN
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const backupPlan = BackupPlan.daily35DayRetention(this, 'StaticMediaBackupPlan', backupVault)
|
|
46
|
+
|
|
47
|
+
backupPlan.addSelection('StaticDeploymentTargetBucket', {
|
|
48
|
+
resources: [BackupResource.fromArn(bucket.bucketArn)],
|
|
49
|
+
allowRestores: true
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const mediaCDN = new Distribution(this, 'Media', {
|
|
53
|
+
defaultBehavior: {
|
|
54
|
+
origin: S3BucketOrigin.withOriginAccessControl(bucket, { originPath: '/media' }),
|
|
55
|
+
// S3 bucket CORS does not add headers on viewer responses; without this, browser fetch()
|
|
56
|
+
// to the CDN is cross-origin and Chrome may block with net::ERR_BLOCKED_BY_ORB.
|
|
57
|
+
responseHeadersPolicy: ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT
|
|
58
|
+
},
|
|
59
|
+
priceClass: PriceClass.PRICE_CLASS_100
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
this.bucket = bucket
|
|
63
|
+
this.cdnDomainName = mediaCDN.domainName
|
|
64
|
+
|
|
65
|
+
new CfnOutput(this, 'MediaCDNDomainName', {
|
|
66
|
+
value: mediaCDN.domainName,
|
|
67
|
+
description: 'Domain name of CDN distribution',
|
|
68
|
+
exportName: `${props.config.platformName}-MediaCDNDomainName`
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
new CfnOutput(this, 'StaticDeploymentTargetBucketName', {
|
|
72
|
+
value: bucket.bucketName,
|
|
73
|
+
description: 'Name of static deployment target bucket',
|
|
74
|
+
exportName: `${props.config.platformName}-StaticDeploymentTargetBucketName`
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
new CfnOutput(this, 'StaticDeploymentTargetBucketArn', {
|
|
78
|
+
value: bucket.bucketArn,
|
|
79
|
+
description: 'ARN of static deployment target bucket',
|
|
80
|
+
exportName: `${props.config.platformName}-StaticDeploymentTargetBucketArn`
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
StorageStaticStack
|
|
87
|
+
}
|
|
@@ -10,10 +10,7 @@ const { logError, logInfo } = require('../log')
|
|
|
10
10
|
* @property {string} awsAccountId - Aws account id
|
|
11
11
|
* @property {string=} awsRegion - ?
|
|
12
12
|
* @property {string=} awsKeyPairName - ?
|
|
13
|
-
* @property {string} awsRoleToAssume - ?
|
|
14
13
|
* @property {string=} awsDeploymentSqsArn - ?
|
|
15
|
-
*
|
|
16
|
-
* @property {string} ciGithubActionsRepo - organisation/repoName
|
|
17
14
|
*/
|
|
18
15
|
|
|
19
16
|
/**
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
const core = require('@actions/core')
|
|
2
|
-
const { STSClient, AssumeRoleWithWebIdentityCommand } = require('@aws-sdk/client-sts')
|
|
3
|
-
|
|
4
|
-
const { logInfo, logError } = require('../log')
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @class
|
|
8
|
-
*/
|
|
9
|
-
class AwsCredentialsService {
|
|
10
|
-
|
|
11
|
-
static resolveAwsCredentials(platformConfig) {
|
|
12
|
-
// If awsRoleToAssume is present, then we assume we run in a github workflow
|
|
13
|
-
// If awsRoleToAssume is not present, then we assume they are resolved localy by aws-sdk
|
|
14
|
-
if (!platformConfig.awsRoleToAssume) {
|
|
15
|
-
logInfo({ message: '[AwsCredentialsService] No aws role to assume was found, leaving auth logic to @aws-sdk package' })
|
|
16
|
-
return Promise.resolve(undefined)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const stsClient = new STSClient({ region: platformConfig.awsRegion })
|
|
20
|
-
|
|
21
|
-
logInfo({ message: '[AwsCredentialsService] Fetching GitHub ID token' })
|
|
22
|
-
return core.getIDToken('sts.amazonaws.com')
|
|
23
|
-
.then(webIdentityToken => {
|
|
24
|
-
logInfo({ message: `[AwsCredentialsService] Attempting to resolve aws credentials by assuming the role: ${platformConfig.awsRoleToAssume}` })
|
|
25
|
-
return stsClient.send(new AssumeRoleWithWebIdentityCommand({
|
|
26
|
-
RoleArn: `arn:aws:iam::${platformConfig.awsAccountId}:role/${platformConfig.awsRoleToAssume}`,
|
|
27
|
-
RoleSessionName: 'GitHubActions',
|
|
28
|
-
DurationSeconds: 15 * 60,
|
|
29
|
-
WebIdentityToken: webIdentityToken
|
|
30
|
-
}))
|
|
31
|
-
})
|
|
32
|
-
.then(responseData => ({
|
|
33
|
-
// Don't ask
|
|
34
|
-
AccessKeyId: responseData.Credentials.AccessKeyId,
|
|
35
|
-
SessionToken: responseData.Credentials.SessionToken,
|
|
36
|
-
SecretAccessKey: responseData.Credentials.SecretAccessKey,
|
|
37
|
-
accessKeyId: responseData.Credentials.AccessKeyId,
|
|
38
|
-
sessionToken: responseData.Credentials.SessionToken,
|
|
39
|
-
secretAccessKey: responseData.Credentials.SecretAccessKey
|
|
40
|
-
}))
|
|
41
|
-
.then(x => AwsCredentialsService.exportCredentialsToGithubWorkflow({ ...x, awsRegion: platformConfig.awsRegion }))
|
|
42
|
-
.catch(error => {
|
|
43
|
-
logError({ message: '[AwsCredentialsService] Could not resolve temporary credentials', error })
|
|
44
|
-
return undefined
|
|
45
|
-
})
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
static exportCredentialsToGithubWorkflow(params) {
|
|
49
|
-
// Configure the AWS CLI and AWS SDKs using environment variables and set them as secrets.
|
|
50
|
-
// Setting the credentials as secrets masks them in Github Actions logs
|
|
51
|
-
const { accessKeyId, secretAccessKey, sessionToken, awsRegion } = params
|
|
52
|
-
|
|
53
|
-
// AWS_ACCESS_KEY_ID:
|
|
54
|
-
// Specifies an AWS access key associated with an IAM user or role
|
|
55
|
-
core.setSecret(accessKeyId)
|
|
56
|
-
core.exportVariable('AWS_ACCESS_KEY_ID', accessKeyId)
|
|
57
|
-
|
|
58
|
-
// AWS_SECRET_ACCESS_KEY:
|
|
59
|
-
// Specifies the secret key associated with the access key. This is essentially the "password" for the access key.
|
|
60
|
-
core.setSecret(secretAccessKey)
|
|
61
|
-
core.exportVariable('AWS_SECRET_ACCESS_KEY', secretAccessKey)
|
|
62
|
-
|
|
63
|
-
// AWS_SESSION_TOKEN:
|
|
64
|
-
// Specifies the session token value that is required if you are using temporary security credentials.
|
|
65
|
-
if (sessionToken) {
|
|
66
|
-
core.setSecret(sessionToken)
|
|
67
|
-
core.exportVariable('AWS_SESSION_TOKEN', sessionToken)
|
|
68
|
-
} else if (process.env.AWS_SESSION_TOKEN) {
|
|
69
|
-
// clear session token from previous credentials action
|
|
70
|
-
core.exportVariable('AWS_SESSION_TOKEN', '')
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (awsRegion) {
|
|
74
|
-
core.exportVariable('AWS_REGION', awsRegion)
|
|
75
|
-
} else if (process.env.AWS_REGION) {
|
|
76
|
-
// clear AWS_REGION from previous credentials action
|
|
77
|
-
core.exportVariable('AWS_REGION', '')
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return params
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
module.exports = {
|
|
86
|
-
AwsCredentialsService
|
|
87
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
const arg = require('arg')
|
|
2
|
-
const { AwsCredentialsService } = require('./aws-credentials')
|
|
3
|
-
const { PlatformTemplateService } = require('../template')
|
|
4
|
-
const { PlatformConfigService } = require('../config')
|
|
5
|
-
const { logInfo, logError } = require('../log')
|
|
6
|
-
|
|
7
|
-
const resolveCredentials = options => {
|
|
8
|
-
logInfo({ message: 'resolve-credentials' })
|
|
9
|
-
|
|
10
|
-
const parsedArgs = arg({
|
|
11
|
-
'--access-key-id': String,
|
|
12
|
-
'--session-token': String,
|
|
13
|
-
'--secret-access-key': String
|
|
14
|
-
}, { argv: options })
|
|
15
|
-
|
|
16
|
-
AwsCredentialsService.exportCredentialsToGithubWorkflow({
|
|
17
|
-
accessKeyId: parsedArgs['--access-key-id'],
|
|
18
|
-
sessionToken: parsedArgs['--session-token'],
|
|
19
|
-
secretAccessKey: parsedArgs['--secret-access-key']
|
|
20
|
-
})
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const assumeRole = options => {
|
|
24
|
-
logInfo({ message: 'assume-role' })
|
|
25
|
-
|
|
26
|
-
const parsedArgs = arg({
|
|
27
|
-
'--platforms': String,
|
|
28
|
-
'--target-platform': String
|
|
29
|
-
}, { argv: options })
|
|
30
|
-
|
|
31
|
-
const platformName = parsedArgs['--target-platform'] || ''
|
|
32
|
-
|
|
33
|
-
PlatformTemplateService.readFromFile(parsedArgs['--platforms'] || process.env.PLATFORMS)
|
|
34
|
-
.then(templates => templates.map(PlatformConfigService.from))
|
|
35
|
-
.then(configs => configs.find(x => x.platformName === platformName))
|
|
36
|
-
.then(targetConfig => {
|
|
37
|
-
|
|
38
|
-
if (!targetConfig) {
|
|
39
|
-
return logError({ message: 'No configuration found' })
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
AwsCredentialsService.resolveAwsCredentials(targetConfig)
|
|
43
|
-
.then(() => logInfo({ message: `Assumed role for ${targetConfig.platformName}` }))
|
|
44
|
-
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
module.exports = {
|
|
50
|
-
handler: ([command, ...options]) => {
|
|
51
|
-
!!command
|
|
52
|
-
? { 'resolve-credentials': resolveCredentials, 'assume-role': assumeRole }[command](options)
|
|
53
|
-
: logError({ message: 'No command provided' })
|
|
54
|
-
}
|
|
55
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
module.exports = require('./aws-credentials')
|
package/src/index.cli.js
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/* eslint-disable global-require, no-unused-vars */
|
|
3
|
-
|
|
4
|
-
const [_, __, handlerName, ...restArgs] = process.argv
|
|
5
|
-
|
|
6
|
-
const loadHandler = {
|
|
7
|
-
aws: () => require('./aws-credentials/cli.js'),
|
|
8
|
-
template: () => require('./template/cli.js'),
|
|
9
|
-
}[handlerName]
|
|
10
|
-
|
|
11
|
-
!!loadHandler && loadHandler().handler(restArgs)
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// TODO: remove this service once ossy.se traffic flows through the platform runtime.
|
|
2
|
-
// The platform runtime (ghcr.io/ossy-se/platform) is the long-term host for all
|
|
3
|
-
// tenant sites including ossy.se. This service is a stopgap while the CMS-based
|
|
4
|
-
// publish flow is validated in production.
|
|
5
|
-
|
|
6
|
-
const systemdServiceFile = `
|
|
7
|
-
[Unit]
|
|
8
|
-
Description=Ossy Website (ossy.se)
|
|
9
|
-
After=network.target docker.service
|
|
10
|
-
Requires=docker.service
|
|
11
|
-
|
|
12
|
-
[Service]
|
|
13
|
-
EnvironmentFile=/etc/environment
|
|
14
|
-
ExecStartPre=-/usr/bin/docker rm -f ossy-website-ossy
|
|
15
|
-
ExecStartPre=/usr/bin/docker pull ghcr.io/ossy-se/website-ossy:latest
|
|
16
|
-
ExecStart=/usr/bin/docker run --name ossy-website-ossy \\
|
|
17
|
-
--network ossy-network \\
|
|
18
|
-
-p 3002:3000 \\
|
|
19
|
-
-e PORT=3000 \\
|
|
20
|
-
-e OSSY_API_URL=http://ossy-api:3001 \\
|
|
21
|
-
-e OSSY_COOKIE_SECRET=\${OSSY_COOKIE_SECRET} \\
|
|
22
|
-
ghcr.io/ossy-se/website-ossy:latest
|
|
23
|
-
ExecStop=/usr/bin/docker stop ossy-website-ossy
|
|
24
|
-
Restart=on-failure
|
|
25
|
-
RestartSec=5
|
|
26
|
-
|
|
27
|
-
[Install]
|
|
28
|
-
WantedBy=multi-user.target
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* @class
|
|
33
|
-
*/
|
|
34
|
-
class OssyWebsiteOssyService {
|
|
35
|
-
|
|
36
|
-
static install() {
|
|
37
|
-
return [`sudo tee /etc/systemd/system/ossy-website-ossy.service > /dev/null << 'EOF'\n${systemdServiceFile}\nEOF`]
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
static enable() {
|
|
41
|
-
return ['sudo systemctl enable ossy-website-ossy']
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
static start() {
|
|
45
|
-
return ['sudo systemctl start ossy-website-ossy']
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
module.exports = {
|
|
51
|
-
OssyWebsiteOssyService
|
|
52
|
-
}
|
package/src/infrastructure/container-deployment-target/ossy-website-plexus-sanitas.service.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// TODO: remove this service once www.plexus-sanitas.com traffic flows through the platform runtime.
|
|
2
|
-
// The platform runtime (ghcr.io/ossy-se/platform) is the long-term host for all
|
|
3
|
-
// tenant sites including plexus-sanitas. This service is a stopgap while the CMS-based
|
|
4
|
-
// publish flow is validated in production.
|
|
5
|
-
|
|
6
|
-
const systemdServiceFile = `
|
|
7
|
-
[Unit]
|
|
8
|
-
Description=Ossy Website (www.plexus-sanitas.com)
|
|
9
|
-
After=network.target docker.service
|
|
10
|
-
Requires=docker.service
|
|
11
|
-
|
|
12
|
-
[Service]
|
|
13
|
-
EnvironmentFile=/etc/environment
|
|
14
|
-
ExecStartPre=-/usr/bin/docker rm -f ossy-website-plexus-sanitas
|
|
15
|
-
ExecStartPre=/usr/bin/docker pull ghcr.io/ossy-se/website-plexus-sanitas:latest
|
|
16
|
-
ExecStart=/usr/bin/docker run --name ossy-website-plexus-sanitas \\
|
|
17
|
-
--network ossy-network \\
|
|
18
|
-
-p 3003:3000 \\
|
|
19
|
-
-e PORT=3000 \\
|
|
20
|
-
-e OSSY_API_URL=http://ossy-api:3001 \\
|
|
21
|
-
-e OSSY_COOKIE_SECRET=\${OSSY_COOKIE_SECRET} \\
|
|
22
|
-
ghcr.io/ossy-se/website-plexus-sanitas:latest
|
|
23
|
-
ExecStop=/usr/bin/docker stop ossy-website-plexus-sanitas
|
|
24
|
-
Restart=on-failure
|
|
25
|
-
RestartSec=5
|
|
26
|
-
|
|
27
|
-
[Install]
|
|
28
|
-
WantedBy=multi-user.target
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* @class
|
|
33
|
-
*/
|
|
34
|
-
class OssyWebsitePlexusSanitasService {
|
|
35
|
-
|
|
36
|
-
static install() {
|
|
37
|
-
return [`sudo tee /etc/systemd/system/ossy-website-plexus-sanitas.service > /dev/null << 'EOF'\n${systemdServiceFile}\nEOF`]
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
static enable() {
|
|
41
|
-
return ['sudo systemctl enable ossy-website-plexus-sanitas']
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
static start() {
|
|
45
|
-
return ['sudo systemctl start ossy-website-plexus-sanitas']
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
module.exports = {
|
|
51
|
-
OssyWebsitePlexusSanitasService
|
|
52
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
const { Stack, Duration, CfnOutput } = require('aws-cdk-lib')
|
|
2
|
-
const {
|
|
3
|
-
OpenIdConnectProvider,
|
|
4
|
-
OpenIdConnectPrincipal,
|
|
5
|
-
Role,
|
|
6
|
-
Effect,
|
|
7
|
-
PolicyDocument,
|
|
8
|
-
PolicyStatement
|
|
9
|
-
} = require('aws-cdk-lib/aws-iam')
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* @class
|
|
13
|
-
*/
|
|
14
|
-
class TrustCiStack extends Stack {
|
|
15
|
-
/**
|
|
16
|
-
* Establishes trust between GithHub and Amazon Web Services.
|
|
17
|
-
* This is needed so that we can interact with Amazon Web Services through
|
|
18
|
-
* GitHub Actions without having to manually provide credentials every time we
|
|
19
|
-
* run a workflow
|
|
20
|
-
*
|
|
21
|
-
* @param {object} scope - scope
|
|
22
|
-
* @param {string} id - id
|
|
23
|
-
* @param {PlatformTemplate} props - PlatformTemplate
|
|
24
|
-
*/
|
|
25
|
-
constructor(scope, id, props) {
|
|
26
|
-
super(scope, id, props)
|
|
27
|
-
|
|
28
|
-
const provider = new OpenIdConnectProvider(this, 'GitHubProvider', {
|
|
29
|
-
url: 'https://token.actions.githubusercontent.com',
|
|
30
|
-
clientIds: ['sts.amazonaws.com']
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
const GitHubPrincipal = new OpenIdConnectPrincipal(provider)
|
|
34
|
-
.withConditions({
|
|
35
|
-
StringLike: {
|
|
36
|
-
'token.actions.githubusercontent.com:sub': `repo:${props.config.ciGithubActionsRepo}:*`
|
|
37
|
-
},
|
|
38
|
-
StringEquals: {
|
|
39
|
-
'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com'
|
|
40
|
-
}
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
// eslint-disable-next-line no-new
|
|
44
|
-
const role = new Role(this, 'GitHubActionsRole', {
|
|
45
|
-
assumedBy: GitHubPrincipal,
|
|
46
|
-
description: 'Role assumed by GitHubPrincipal for deploying from CI using aws cdk',
|
|
47
|
-
maxSessionDuration: Duration.hours(1),
|
|
48
|
-
inlinePolicies: {
|
|
49
|
-
CdkDeploymentPolicy: new PolicyDocument({
|
|
50
|
-
assignSids: true,
|
|
51
|
-
statements: [
|
|
52
|
-
new PolicyStatement({
|
|
53
|
-
effect: Effect.ALLOW,
|
|
54
|
-
actions: ['sts:AssumeRole', 'sqs:SendMessage'],
|
|
55
|
-
resources: [
|
|
56
|
-
`arn:aws:iam::${props.config.awsAccountId}:role/cdk-*`,
|
|
57
|
-
`arn:aws:sqs:${props.config.awsRegion}:${props.config.awsAccountId}:*`
|
|
58
|
-
]
|
|
59
|
-
})
|
|
60
|
-
]
|
|
61
|
-
})
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
new CfnOutput(this, 'CiAwsRoleToAssume', {
|
|
66
|
-
value: role.roleName,
|
|
67
|
-
description: 'Name of role to be assumed in CI/CD pipelines on github',
|
|
68
|
-
exportName: 'awsRoleToAssume'
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
module.exports = {
|
|
75
|
-
TrustCiStack
|
|
76
|
-
}
|
package/src/template/cli.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
const arg = require('arg')
|
|
2
|
-
const { PlatformTemplateService } = require('./platform-template')
|
|
3
|
-
|
|
4
|
-
const { logInfo, logError } = require('../log')
|
|
5
|
-
|
|
6
|
-
const validate = options => {
|
|
7
|
-
logInfo({ message: 'Running validate command' })
|
|
8
|
-
|
|
9
|
-
const parsedArgs = arg({
|
|
10
|
-
'--platforms': String,
|
|
11
|
-
'-p': '--platforms'
|
|
12
|
-
}, { argv: options })
|
|
13
|
-
|
|
14
|
-
PlatformTemplateService.readFromFile(parsedArgs['--platforms'])
|
|
15
|
-
.then(() => logInfo({ message: 'Template is valid' }))
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
module.exports = {
|
|
19
|
-
handler: ([command, ...options]) => {
|
|
20
|
-
!!command
|
|
21
|
-
? { validate }[command](options)
|
|
22
|
-
: logError({ message: 'No command provided' })
|
|
23
|
-
}
|
|
24
|
-
}
|