diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index a2188bfd..321bcdfd 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -8,7 +8,7 @@ jobs: uses: bn-digital/vault/.github/workflows/import-secrets.yml@latest secrets: inherit - database: + bootstrap: needs: [secrets] runs-on: self-hosted steps: @@ -24,6 +24,19 @@ jobs: infrastructure/data/digitalocean token | DIGITALOCEAN_TOKEN ; infrastructure/data/kubernetes cluster | KUBERNETES_CLUSTER ; infrastructure/data/postgresql password | PGPASSWORD; + accounts/data/dcr.bndigital.dev api_token | HARBOR_TOKEN; + + - name: Create project in Harbor + run: | + curl -X 'POST' \ + 'https://dcr.bndigital.dev/api/v2.0/projects' \ + -H 'authorization: Basic ${{ secrets.HARBOR_TOKEN }}' \ + -H 'Content-Type: application/json' \ + -d '{ + "project_name": "${{ github.event.repository.name }}", + "public": false, + "storage_limit": 10737418240 + }' - name: Setup DigitalOcean cli uses: digitalocean/action-doctl@v2 with: diff --git a/README.md b/README.md index 0897cea8..8cc612ab 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,24 @@ To ensure that WFs are working properly, add these GH Secrets right after project creation: - [ ] Secrets for `VAULT_TOKEN` and `VAULT_ENDPOINT` (copy `github-token` and `url` from [https://vault.bndigital.dev](https://vault.bndigital.dev/ui/vault/secrets/infrastructure/show/vault)) - [ ] Secret for `GH_TOKEN` (copy `github-token` from [https://vault.bndigital.dev](https://vault.bndigital.dev/ui/vault/secrets/accounts/show/github/bn-enginseer)) +- [ ] Secret for `HARBOR_TOKEN` (copy `api-token` from [https://vault.bndigital.dev](https://vault.bndigital.dev/ui/vault/secrets/accounts/show/dcr.bndigital.dev)) ## Staging Rollout -After setting up secrets, perform these actions: -- [ ] Create new project in [https://dcr.bndigital.dev/] (click `+ New project`, in pop-up window only enter `Project Name`, left other fields unchanged and click `OK` (credentials are here: [https://vault.bndigital.dev](https://vault.bndigital.dev/ui/vault/secrets/accounts/show/dcr.bndigital.dev))) +After setting up **_all_**(!) required secrets, perform these actions: - [ ] In the `Actions` tab, find WF named `Bootstrap` in the left pane - [ ] Click on the button `Run workflow` in the top-right corner and in pop-up window click green button `Run workflow` -- [ ] After that replace all occurrences of `project-templates` with the name of your project +- [ ] After that replace all occurrences of `"project-templates"` in all `package.json` files with the name of your project, run `yarn install` from root, then commit and push these changes - [ ] Wait for the `Staging Deployment` workflow to run and check your staging at `REPO_NAME.bndigital.dev` +- [ ] Go to `REPO_NAME.bndigital.dev/admin` and set up Strapi Admin with [these](https://vault.bndigital.dev/ui/vault/secrets/templates/show/project/staging/strapi) credentials +- [ ] Click on the `Settings` option in the menu on the left and on the page that appeared click `Transfer tokens` and then `+ Create new Transfer Token` button inn the top-right corner +- [ ] In the appeared window enter these values: +- For `Name` any name you like (e.g staging_token) +- For `Token duration` select `30 days` +- For `Description` don't put anything +- For `Token type` select `Full access` +- [ ] Then copy appeared token, press `Save` and save it as a GH secret named `STRAPI_STAGING_TRANSFER_TOKEN` +- [ ] Finally, run workflow named `Transfer` the same way you ran `Bootstrap` - [ ] Say a few kind words to yourself after completing all steps 🙃 ## Documentation & References @@ -29,7 +38,7 @@ After setting up secrets, perform these actions: - [React Documentation](https://react.dev/reference/react) - [Strapi CMS Documentation](https://docs.strapi.io/developer-docs/latest/getting-started/introduction.html) - [Ant Design Components](https://ant.design/components/overview/) -- [Github Actions](https://docs.github.com/en/actions) +- [GitHub Actions](https://docs.github.com/en/actions) - [Build and run as Docker container](docs/docker.md) - [Environment variables](docs/env-variables.md) - [Backend object storage](docs/storage.md) @@ -40,7 +49,7 @@ After setting up secrets, perform these actions: [Run in sandbox](https://codesandbox.io/p/github/bn-digital/project-templates/latest) -### Operations tools +### Official documentation for tools in use - [docker](https://docs.docker.com/) - [kubectl](https://github.com/kubernetes/kubectl) diff --git a/packages/cloud/Pulumi.staging.yaml b/packages/cloud/Pulumi.staging.yaml deleted file mode 100644 index 342433e1..00000000 --- a/packages/cloud/Pulumi.staging.yaml +++ /dev/null @@ -1,19 +0,0 @@ -secretsprovider: hashivault://pulumi -encryptedkey: dmF1bHQ6djE6b0VZQStZSXFhbks0dC95MVlNdi9JSFBRT2syanE3eGpLd0h6VmtXRlhqbGpObHFFUzU0VFdVZlFlSkdUWEhRZlFaWFVQaEZjZHZHK256VnM= -config: - project:spec: - githubSecrets: - infrastructure/digitalocean: - DIGITALOCEAN_TOKEN: token - infrastructure/sonarqube: - SONAR_HOST_URL: url - SONAR_TOKEN: token - infrastructure/spaces: - SPACES_ACCESS_KEY_ID: access-key-id - SPACES_SECRET_ACCESS_KEY: secret-access-key - infrastructure/vault: - VAULT_ENDPOINT: url - VAULT_TOKEN: pulumi-token - domain: app.bndigital.dev - region: nyc3 -# nodePoolName: projects diff --git a/packages/cloud/package.json b/packages/cloud/package.json index e7890523..40276151 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -1,18 +1,23 @@ { "name": "@project-templates/cloud", - "version": "2023.6.8", + "version": "1.0.0", "main": "src/index.ts", + "eslintConfig": { + "extends": "@bn-digital/eslint-config/typescript" + }, "scripts": { - "pulumi": "pulumi", - "test": "pulumi up --policy-pack=tests", - "provision": "yarn pulumi up --yes" + "provision": "pulumi up --non-interactive --stack=production --yes" }, "dependencies": { - "@bn-digital/pulumi": "1.7.51", - "@pulumi/pulumi": "3.69.x" + "@pulumi/digitalocean": "^4.17.0", + "@pulumi/kubernetes": "^3.23.1", + "@pulumi/pulumi": "^3.62.0", + "@pulumi/vault": "^5.8.0", + "dotenv": "^16.0.3" }, "devDependencies": { - "@bn-digital/typescript-config": "^1.3.6", - "@pulumi/policy": "^1.5.0" + "@bn-digital/typescript-config": "^1.3.1", + "@types/node": "^18.15.11", + "typescript": "^5.0.4" } } diff --git a/packages/cloud/src/digitalocean.ts b/packages/cloud/src/digitalocean.ts new file mode 100644 index 00000000..c9a62d58 --- /dev/null +++ b/packages/cloud/src/digitalocean.ts @@ -0,0 +1,165 @@ +import { + Cdn, + Certificate, + DnsRecord, + Domain, + DropletSlug, + KubernetesCluster, + Project, + Region, + SpacesBucket, + SpacesBucketPolicy, + getKubernetesVersions, + CdnArgs, + DnsRecordArgs, + DomainArgs, + KubernetesClusterArgs, + ProjectArgs, + SpacesBucketArgs, +} from "@pulumi/digitalocean" +import { Config, Input, Output } from "@pulumi/pulumi" + +const config = new Config() +const name = config.name +const { + region, + domain: dns = "", + cdn = false, +} = config.requireObject<{ region: Region; cdn?: boolean; domain?: string }>("digitalocean") +const environment = "production" +const tags = ["provisioner:pulumi", `environment:${environment}`, `app:${name}`] + +type ResourceID = "provider" | "cluster" | "storage" | "cdn" | "dns" | "project" | "certificate" | "policy" + +function urn(resource: ResourceID, ...tags: string[]) { + return [["bn", name, environment].join(":"), [resource as string].concat(tags).filter(Boolean).join(":")].join("/") +} + +function getCmsPolicy(bucket: string | Input) { + return { + Version: "2012-10-17", + Statement: [ + { + Sid: "CmsPublicAccess", + Effect: "Allow", + Action: ["s3:PutObject", "s3:GetObject", "s3:ListBucket", "s3:DeleteObject", "s3:PutObjectAcl"], + Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`], + }, + ], + } +} + +/** + * Create a DigitalOcean Spaces bucket required for CMS uploads and assets + * @param {Domain} domain + */ +function createBucket(domain?: string): SpacesBucket { + const bucket = new SpacesBucket( + urn("storage", "cms"), + { + acl: "public-read", + name: `${name}-cms`, + region, + versioning: { enabled: false }, + forceDestroy: true, + }, + { ignoreChanges: ["name", "region"] as (keyof SpacesBucketArgs)[] } + ) + new SpacesBucketPolicy( + urn("storage", "cms", "policy"), + { policy: JSON.stringify(getCmsPolicy(`${name}-cms`)), region, bucket: `${name}-cms` }, + { + dependsOn: [bucket], + } + ) + if (cdn) { + const cdnRecord = new DnsRecord( + urn("storage", "cms", "cdn", "dns"), + { type: "CNAME", name: "cdn", value: bucket.bucketDomainName.apply(fqdn => `${fqdn}.`), domain: dns }, + { + ignoreChanges: [] as (keyof DnsRecordArgs)[], + } + ) + const certificate = new Certificate( + urn("storage", "cms", "cdn", "certificate"), + { + domains: [`cdn.${dns}`], + name, + type: "lets_encrypt", + }, + { ignoreChanges: ["name"] as (keyof DomainArgs)[], dependsOn: [cdnRecord] } + ) + new Cdn( + urn("storage", "cms", "cdn"), + { + origin: bucket.bucketDomainName, + customDomain: cdnRecord.fqdn, + certificateName: certificate.name, + }, + { ignoreChanges: [] as (keyof CdnArgs)[], dependsOn: [certificate, cdnRecord, bucket] } + ) + } + + return bucket +} + +function createDomain(name: string): Domain { + return new Domain(urn("dns"), { + name, + }) +} + +/** + * @param name + * @param version + */ +function createCluster(name: string, version: string): KubernetesCluster { + return new KubernetesCluster( + urn("cluster"), + { + ha: false, + surgeUpgrade: true, + autoUpgrade: false, + name, + nodePool: { + name: "production", + size: DropletSlug.DropletS2VCPU4GB_INTEL, + minNodes: 1, + maxNodes: 2, + autoScale: true, + }, + region, + version, + tags, + }, + { ignoreChanges: ["name", "version", "region"] as (keyof KubernetesClusterArgs)[] } + ) +} + +function createProject(resources: Output[]): Project { + return new Project( + urn("project"), + { + environment, + name, + purpose: "Web Application", + resources, + }, + { ignoreChanges: [] as (keyof ProjectArgs)[] } + ) +} + +export async function run() { + const name = config.name + const version = await getKubernetesVersions().then(versions => versions.latestVersion) + const cluster = createCluster(name, version) + const resources = [cluster.clusterUrn] + let domain = undefined + if (dns) { + domain = createDomain(dns) + resources.push(domain.domainUrn) + } + const bucket = domain?.name.apply(createBucket) || createBucket() + resources.push(bucket.bucketUrn) + return createProject(resources) +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index a89c52d4..8c20a4f7 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -1,7 +1,7 @@ -import { configure, providers, stacks } from "@bn-digital/pulumi" -import { Config } from "@pulumi/pulumi" -import "./types" +import { config } from "dotenv" -const spec = new Config("project").requireObject("spec") +import { run } from "./digitalocean" -configure(spec).cloud(stacks.digitalocean.CloudNativeWebApp).deploy(providers.helm.WebAppDeployment).release() +config() + +run().then() diff --git a/packages/cloud/src/types/index.d.ts b/packages/cloud/src/types/index.d.ts index 877993a3..6214cc1f 100644 --- a/packages/cloud/src/types/index.d.ts +++ b/packages/cloud/src/types/index.d.ts @@ -4,4 +4,4 @@ declare global { type ConfigSpec = stacks.InfrastructureConfig & providers.helm.AppConfig } -export {} +export {} \ No newline at end of file diff --git a/packages/cloud/tsconfig.json b/packages/cloud/tsconfig.json index 95b26a66..0852c92f 100644 --- a/packages/cloud/tsconfig.json +++ b/packages/cloud/tsconfig.json @@ -1,9 +1,12 @@ { - "extends": "@bn-digital/typescript-config/pulumi", + "extends": "@bn-digital/typescript-config/pulumi.json", "compilerOptions": { "baseUrl": ".", - "outDir": "bin", - "lib": ["esnext", "dom"] + "esModuleInterop": true, + "outDir": "build", + "noEmit": true, + "target": "ESNext", + "resolveJsonModule": true }, - "include": ["./src", "./package.json"] + "include": ["src", "./package.json"] }