├── .gitmodules ├── creds.json ├── .npmrc ├── .gitconfig ├── bin ├── dev.cmd ├── run.cmd ├── readme.cmd ├── readme ├── run ├── dev └── readme-mdx.mjs ├── test ├── integration │ ├── stateless-component │ │ ├── .dockerignore │ │ ├── public │ │ │ └── img │ │ │ │ ├── favicon.ico │ │ │ │ └── logo.svg │ │ ├── Dockerfile │ │ ├── next.config.js │ │ ├── .gitignore │ │ ├── package.json │ │ ├── src │ │ │ ├── pages │ │ │ │ ├── _app.js │ │ │ │ └── index.js │ │ │ └── server.js │ │ ├── architect.yml │ │ └── README.md │ ├── stateful-component │ │ ├── backend │ │ │ ├── .gitignore │ │ │ ├── Dockerfile │ │ │ ├── package.json │ │ │ └── src │ │ │ │ ├── migration.js │ │ │ │ └── index.js │ │ ├── frontend │ │ │ ├── public │ │ │ │ └── img │ │ │ │ │ ├── favicon.ico │ │ │ │ │ └── logo.svg │ │ │ ├── next.config.js │ │ │ ├── Dockerfile │ │ │ ├── .gitignore │ │ │ ├── package.json │ │ │ └── src │ │ │ │ ├── pages │ │ │ │ └── _app.js │ │ │ │ └── server.js │ │ ├── architect.yml │ │ └── README.md │ ├── hello-world │ │ ├── Dockerfile │ │ ├── package.json │ │ ├── index.js │ │ ├── architect.yml │ │ └── README.md │ └── scheduled-tasks │ │ ├── architect.yml │ │ ├── Dockerfile │ │ └── Readme.md ├── mocks │ ├── superset │ │ ├── filedata.txt │ │ └── deprecated.architect.yml │ ├── secrets │ │ ├── cluster-secrets.yml │ │ ├── environment-secrets.yml │ │ └── account-secrets.yml │ ├── cors │ │ └── architect.yml │ ├── validationerrors │ │ └── architect.yml │ ├── ingress │ │ ├── downstream │ │ │ └── architect.yml │ │ └── upstream │ │ │ └── architect.yml │ ├── register │ │ ├── architect.yml │ │ └── nonexistence-dockerfile-architect.yml │ ├── buildpack │ │ ├── buildpack-architect.yml │ │ └── buildpack-dockerfile-architect.yml │ ├── examples │ │ ├── hello-world.architect.yml │ │ └── database-seeding.architect.yml │ └── deprecations │ │ └── liveness-probe-path-port.architect.yml ├── dependency-manager │ ├── spec │ │ └── partials │ │ │ ├── root │ │ │ ├── services │ │ │ │ ├── _image │ │ │ │ │ └── image.yml │ │ │ │ ├── _build │ │ │ │ │ ├── basic-build.yml │ │ │ │ │ ├── build-with-dockerfile.yml │ │ │ │ │ └── build-with-args.yml │ │ │ │ ├── _environment │ │ │ │ │ ├── basic-environment.yml │ │ │ │ │ ├── host-override-interface.yml │ │ │ │ │ ├── multiple-environment.yml │ │ │ │ │ └── parameterized-environment.yml │ │ │ │ ├── _interfaces │ │ │ │ │ ├── basic-service-interface.yml │ │ │ │ │ ├── multiple-service-interfaces.yml │ │ │ │ │ ├── host-override-interface.yml │ │ │ │ │ ├── parameterized-host-override.yml │ │ │ │ │ └── full-service-interface.yml │ │ │ │ ├── service-with-command.yml │ │ │ │ ├── service-with-entrypoint.yml │ │ │ │ ├── basic-service.yml │ │ │ │ ├── service-with-command-array.yml │ │ │ │ └── _liveness_probe │ │ │ │ │ └── basic-liveness-probe.yml │ │ │ ├── databases │ │ │ │ └── basic-database.yml │ │ │ ├── dependencies │ │ │ │ ├── one-dependency.yml │ │ │ │ └── multiple-dependencies.yml │ │ │ ├── interfaces │ │ │ │ ├── basic-interface.yml │ │ │ │ ├── full-interface.yml │ │ │ │ └── ingress-interface.yml │ │ │ ├── tasks │ │ │ │ ├── basic-task.yml │ │ │ │ ├── task-without-schedule.yml │ │ │ │ ├── task-with-basic-schedule.yml │ │ │ │ ├── task-with-schedule.yml │ │ │ │ ├── task-with-schedule-params.yml │ │ │ │ └── task-with-command-array.yml │ │ │ └── architect.yml │ │ │ └── README.md │ ├── examples-validation.test.ts │ └── schema │ │ ├── component-transform.unit.test.ts │ │ └── component-builder.unit.test.ts ├── webpack │ ├── test.js │ ├── package.json │ └── webpack.config.js ├── .mocharc.yml ├── traefik.yaml ├── config.json ├── commands │ ├── config │ │ ├── get.test.ts │ │ └── view.test.ts │ ├── logout.test.ts │ ├── login.test.ts │ ├── clusters │ │ └── destroy.test.ts │ ├── platforms │ │ └── destroy.test.ts │ ├── destroy.test.ts │ ├── link │ │ ├── list.test.ts │ │ ├── index.test.ts │ │ └── unlink.test.ts │ ├── components │ │ ├── versions.test.ts │ │ └── index.test.ts │ ├── environments │ │ └── index.test.ts │ └── port-forward.test.ts └── test-setup.ts ├── src ├── architect │ ├── types.ts │ ├── account │ │ └── account.entity.ts │ ├── user │ │ ├── user.entity.ts │ │ └── user.utils.ts │ ├── environment │ │ └── environment.entity.ts │ ├── component │ │ ├── component.entity.ts │ │ └── component-version.entity.ts │ ├── pipeline │ │ └── pipeline.entity.ts │ ├── cluster │ │ └── cluster.entity.ts │ ├── deployment │ │ └── deployment.entity.ts │ └── secret │ │ └── secret.utils.ts ├── dependency-manager │ ├── graph │ │ ├── type.ts │ │ ├── edge │ │ │ ├── ingress.ts │ │ │ ├── service.ts │ │ │ ├── ingress-consumer.ts │ │ │ └── index.ts │ │ ├── state.ts │ │ └── node │ │ │ ├── gateway.ts │ │ │ ├── index.ts │ │ │ ├── task.ts │ │ │ └── service.ts │ ├── spec │ │ ├── utils │ │ │ ├── interpolation.ts │ │ │ └── yaml.ts │ │ ├── transform │ │ │ ├── task-transform.ts │ │ │ ├── database-transform.ts │ │ │ ├── service-transform.ts │ │ │ └── common-transform.ts │ │ ├── database-spec.ts │ │ ├── secret-spec.ts │ │ └── task-spec.ts │ ├── config │ │ ├── task-config.ts │ │ ├── common-config.ts │ │ ├── resource-config.ts │ │ ├── component-context.ts │ │ └── service-config.ts │ ├── secrets │ │ └── type.ts │ ├── utils │ │ ├── types.ts │ │ ├── regex.ts │ │ ├── match.ts │ │ ├── transform.ts │ │ ├── dictionary.ts │ │ ├── files.ts │ │ └── refs.ts │ ├── deprecated-spec │ │ └── index.ts │ ├── generate.ts │ └── README.md ├── common │ ├── utils │ │ ├── types.ts │ │ ├── offline.ts │ │ ├── secret │ │ │ └── helper.ts │ │ ├── oras.ts │ │ ├── localized-timestamp.ts │ │ ├── oclif.ts │ │ └── git │ │ │ └── helper.ts │ ├── docker-compose │ │ ├── project.ts │ │ └── template.ts │ ├── errors │ │ ├── login-required.ts │ │ ├── invalid-config-option.ts │ │ ├── missing-build-context.ts │ │ └── pipeline-errors.ts │ ├── docker │ │ ├── cmd.ts │ │ └── index.ts │ ├── plugins │ │ ├── plugin-types.ts │ │ ├── plugin-utils.ts │ │ └── plugin-manager.ts │ ├── overlay │ │ └── overlay-server.ts │ ├── kubectl │ │ └── helper.ts │ └── dependency-manager │ │ └── validation.ts ├── base-table.ts ├── commands │ ├── platforms │ │ ├── index.ts │ │ ├── create.ts │ │ └── destroy.ts │ ├── whoami.ts │ ├── link │ │ ├── list.ts │ │ └── index.ts │ ├── logout.ts │ ├── config │ │ ├── view.ts │ │ ├── get.ts │ │ └── set.ts │ ├── components │ │ ├── versions.ts │ │ └── index.ts │ ├── environments │ │ ├── ingresses.ts │ │ └── index.ts │ ├── unlink.ts │ ├── clusters │ │ └── index.ts │ ├── destroy.ts │ └── dev │ │ └── list.ts ├── hooks │ └── init │ │ ├── check-version.ts │ │ └── tty.ts ├── paths.ts ├── static │ ├── login_callback_success.html │ ├── login_callback_failure.html │ └── doctor.html ├── app-config │ ├── credentials.ts │ ├── posthog.ts │ └── callback-server.ts └── index.ts ├── .eslintignore ├── .madgerc ├── .eslintrc.js ├── .prettierignore ├── .editorconfig ├── .husky └── pre-commit ├── Dockerfile ├── .gitignore ├── .prettierrc ├── lint-staged.config.js ├── .github ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── register-examples.yml ├── tsconfig.json ├── patches └── @oclif+core+1.19.0.patch └── release.config.js /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /creds.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.gitconfig: -------------------------------------------------------------------------------- 1 | [core] 2 | hookspath = git_hooks 3 | -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\dev" %* 4 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /bin/readme.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\readme" %* 4 | -------------------------------------------------------------------------------- /test/integration/stateless-component/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /test/mocks/superset/filedata.txt: -------------------------------------------------------------------------------- 1 | file data referenced in superset.yml 2 | -------------------------------------------------------------------------------- /src/architect/types.ts: -------------------------------------------------------------------------------- 1 | export type Paginate = { total: number, rows: T[] }; 2 | -------------------------------------------------------------------------------- /test/integration/stateful-component/backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | logs/ 3 | -------------------------------------------------------------------------------- /test/mocks/secrets/cluster-secrets.yml: -------------------------------------------------------------------------------- 1 | '*': 2 | cluster-secret: cluster-val 3 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_image/image.yml: -------------------------------------------------------------------------------- 1 | heroku/nodejs-hello-world 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test/ 3 | examples/ 4 | lib/ 5 | bin/ 6 | lint-staged.config.js 7 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_build/basic-build.yml: -------------------------------------------------------------------------------- 1 | context: ./sub/directory 2 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/databases/basic-database.yml: -------------------------------------------------------------------------------- 1 | main-db: 2 | type: postgres:12 3 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/dependencies/one-dependency.yml: -------------------------------------------------------------------------------- 1 | tests/one-dependency: latest 2 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_environment/basic-environment.yml: -------------------------------------------------------------------------------- 1 | LOG_LEVEL: info 2 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_interfaces/basic-service-interface.yml: -------------------------------------------------------------------------------- 1 | main: 8080 2 | -------------------------------------------------------------------------------- /test/mocks/secrets/environment-secrets.yml: -------------------------------------------------------------------------------- 1 | '*': 2 | secret: override 3 | new-secret: new-secret-val 4 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/service-with-command.yml: -------------------------------------------------------------------------------- 1 | hello: 2 | command: bin/web 3 | -------------------------------------------------------------------------------- /.madgerc: -------------------------------------------------------------------------------- 1 | { 2 | "detectiveOptions": { 3 | "ts": { 4 | "skipTypeImports": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/service-with-entrypoint.yml: -------------------------------------------------------------------------------- 1 | hello: 2 | entrypoint: bin/web 3 | -------------------------------------------------------------------------------- /test/mocks/secrets/account-secrets.yml: -------------------------------------------------------------------------------- 1 | cloud/*: 2 | secret: override 3 | '*': 4 | new-secret: new-secret-val 5 | -------------------------------------------------------------------------------- /src/architect/account/account.entity.ts: -------------------------------------------------------------------------------- 1 | export default interface Account { 2 | id: string; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/interfaces/basic-interface.yml: -------------------------------------------------------------------------------- 1 | api: ${{ services.api.interfaces.main.url }} 2 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/tasks/basic-task.yml: -------------------------------------------------------------------------------- 1 | curler: 2 | image: ellerbrock/alpine-bash-curl-ssl 3 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_interfaces/multiple-service-interfaces.yml: -------------------------------------------------------------------------------- 1 | main: 8080 2 | admin: 8081 3 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/type.ts: -------------------------------------------------------------------------------- 1 | export interface GraphOptions { 2 | interpolate?: boolean; 3 | validate?: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_build/build-with-dockerfile.yml: -------------------------------------------------------------------------------- 1 | context: ./ 2 | dockerfile: Dockerfile.debug 3 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/tasks/task-without-schedule.yml: -------------------------------------------------------------------------------- 1 | curler: 2 | image: ellerbrock/alpine-bash-curl-ssl 3 | -------------------------------------------------------------------------------- /src/architect/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | export default interface User { 2 | id: string; 3 | name: string; 4 | email: string; 5 | } 6 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_interfaces/host-override-interface.yml: -------------------------------------------------------------------------------- 1 | main: 2 | host: "10.0.0.16" 3 | port: 8080 4 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/basic-service.yml: -------------------------------------------------------------------------------- 1 | hello: 2 | description: Hello world test service 3 | language: javascript 4 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_build/build-with-args.yml: -------------------------------------------------------------------------------- 1 | context: ./ 2 | args: 3 | NODE_ENV: development 4 | UNSET_ARG: null 5 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_environment/host-override-interface.yml: -------------------------------------------------------------------------------- 1 | HOST: '10.0.0.16' 2 | PORT: 8080 3 | ARRAY: ['one', 'two'] 4 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_environment/multiple-environment.yml: -------------------------------------------------------------------------------- 1 | LOG_LEVEL: info 2 | API_USERNAME: architect 3 | API_PORT: 3000 4 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/service-with-command-array.yml: -------------------------------------------------------------------------------- 1 | hello: 2 | command: 3 | - echo "starting up..." 4 | - bin/web 5 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/tasks/task-with-basic-schedule.yml: -------------------------------------------------------------------------------- 1 | curler: 2 | image: ellerbrock/alpine-bash-curl-ssl 3 | schedule: "* * * * *" 4 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/tasks/task-with-schedule.yml: -------------------------------------------------------------------------------- 1 | curler: 2 | image: ellerbrock/alpine-bash-curl-ssl 3 | schedule: "1-9 * */5 * *" 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line unicorn/prefer-module 2 | module.exports = { 3 | extends: [ 4 | '@architect-io/eslint-config/oclif', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/edge/ingress.ts: -------------------------------------------------------------------------------- 1 | import { DependencyEdge } from '.'; 2 | 3 | export class IngressEdge extends DependencyEdge { 4 | __type = 'ingress'; 5 | } 6 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/edge/service.ts: -------------------------------------------------------------------------------- 1 | import { DependencyEdge } from '.'; 2 | 3 | export class ServiceEdge extends DependencyEdge { 4 | __type = 'service'; 5 | } 6 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/interfaces/full-interface.yml: -------------------------------------------------------------------------------- 1 | api: 2 | url: ${{ services.api.interfaces.main.url }} 3 | description: Some friendly description 4 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/tasks/task-with-schedule-params.yml: -------------------------------------------------------------------------------- 1 | curler: 2 | image: ellerbrock/alpine-bash-curl-ssl 3 | schedule: ${{ secrets.schedule }} 4 | -------------------------------------------------------------------------------- /test/webpack/test.js: -------------------------------------------------------------------------------- 1 | const { validateOrRejectSpec } = require('../../lib/index.js'); 2 | 3 | validateOrRejectSpec({ name: 'my-component' }); 4 | console.log('Valid spec!'); 5 | -------------------------------------------------------------------------------- /bin/readme: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('reflect-metadata') 4 | const ReadmeCommand = require('oclif/lib/commands/readme') 5 | ReadmeCommand.default.run(['--no-aliases']) 6 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_interfaces/parameterized-host-override.yml: -------------------------------------------------------------------------------- 1 | main: 2 | host: ${{ secrets.main_host }} 3 | port: ${{ secrets.main_port }} 4 | -------------------------------------------------------------------------------- /test/integration/stateless-component/public/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architect-team/architect-cli/HEAD/test/integration/stateless-component/public/img/favicon.ico -------------------------------------------------------------------------------- /test/integration/stateful-component/frontend/public/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architect-team/architect-cli/HEAD/test/integration/stateful-component/frontend/public/img/favicon.ico -------------------------------------------------------------------------------- /test/mocks/cors/architect.yml: -------------------------------------------------------------------------------- 1 | name: tests/cors 2 | 3 | services: 4 | service-with-cors: 5 | image: postgres:12 6 | environment: 7 | CORS_URLS: ${{ ingresses.main.consumers }} 8 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/edge/ingress-consumer.ts: -------------------------------------------------------------------------------- 1 | import { DependencyEdge } from '.'; 2 | 3 | export class IngressConsumerEdge extends DependencyEdge { 4 | __type = 'ingress-consumer'; 5 | } 6 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/dependencies/multiple-dependencies.yml: -------------------------------------------------------------------------------- 1 | tests/one-dependency: latest 2 | tests/two-dependency: 1.0.0 3 | tests/three-dependency: ${{ secrets.dependency }} 4 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/utils/interpolation.ts: -------------------------------------------------------------------------------- 1 | export const EXPRESSION_REGEX = new RegExp(`\\\${{\\s*(.*?)\\s*}}`, 'g'); 2 | export const IF_EXPRESSION_REGEX = new RegExp(`^\\\${{\\s*if(.*?)\\s*}}$`); 3 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_interfaces/full-service-interface.yml: -------------------------------------------------------------------------------- 1 | main: 2 | port: 8080 3 | protocol: http 4 | description: This interface connects to the hello-world API 5 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_liveness_probe/basic-liveness-probe.yml: -------------------------------------------------------------------------------- 1 | success_threshold: 1 2 | failure_threshold: 3 3 | timeout: 5s 4 | interval: 0s 5 | path: /bin/health 6 | port: 8080 7 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/tasks/task-with-command-array.yml: -------------------------------------------------------------------------------- 1 | curler: 2 | image: ellerbrock/alpine-bash-curl-ssl 3 | command: 4 | - sh 5 | - -c 6 | - curl https://www.google.com 7 | -------------------------------------------------------------------------------- /src/dependency-manager/config/task-config.ts: -------------------------------------------------------------------------------- 1 | import { ResourceConfig } from './resource-config'; 2 | 3 | export interface TaskConfig extends ResourceConfig { 4 | debug?: TaskConfig; 5 | schedule?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/dependency-manager/secrets/type.ts: -------------------------------------------------------------------------------- 1 | import { SecretSpecValue } from '../spec/secret-spec'; 2 | import { Dictionary } from '../utils/dictionary'; 3 | 4 | export type SecretsDict = Dictionary>; 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .husky 2 | .bin 3 | lib 4 | patches 5 | node_modules 6 | package* 7 | .eslintrc.js 8 | .eslintignore 9 | .git* 10 | .prettierignore 11 | *Dockerfile 12 | src/dependency-manager/schema/architect.schema.json -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /src/architect/environment/environment.entity.ts: -------------------------------------------------------------------------------- 1 | import Cluster from '../cluster/cluster.entity'; 2 | 3 | export default interface Environment { 4 | id: string; 5 | name: string; 6 | namespace: string; 7 | cluster: Cluster; 8 | } 9 | -------------------------------------------------------------------------------- /test/.mocharc.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - ts-node/register 3 | - source-map-support/register 4 | - ./test/test-setup.ts 5 | recursive: true 6 | reporter: spec 7 | timeout: 20000 8 | trace-warnings: true 9 | watch-extensions: 10 | - ts 11 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/architect.yml: -------------------------------------------------------------------------------- 1 | name: tests/component-spec 2 | description: Mock, partial component spec 3 | keywords: 4 | - test-component 5 | - mock-component 6 | author: Dan B 7 | homepage: https://architect.io 8 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/services/_environment/parameterized-environment.yml: -------------------------------------------------------------------------------- 1 | API_HOST: ${{ services.api.interfaces.main.host }} 2 | API_PORT: ${{ services.api.interfaces.main.host }} 3 | LOG_LEVEL: ${{ secrets.log_level }} 4 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/root/interfaces/ingress-interface.yml: -------------------------------------------------------------------------------- 1 | api: 2 | url: ${{ services.api.interfaces.main.url }} 3 | description: Some friendly description 4 | ingress: 5 | enabled: true 6 | subdomain: api-subdomain 7 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # https://typicode.github.io/husky/#/?id=command-not-found 5 | export NVM_DIR="$HOME/.nvm" 6 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 7 | 8 | npx --no-install lint-staged 9 | -------------------------------------------------------------------------------- /test/integration/stateful-component/frontend/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack(config) { 3 | config.module.rules.push({ 4 | test: /\.svg$/, 5 | use: ['@svgr/webpack'], 6 | }); 7 | 8 | return config; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /test/mocks/validationerrors/architect.yml: -------------------------------------------------------------------------------- 1 | name: validation_errors 2 | 3 | services: 4 | validation_errors: 5 | interfaces: 6 | main: 8080 7 | 8 | interfaces: 9 | validation_errors: ${{ services.validation_errors.interfaces.main.url }} 10 | -------------------------------------------------------------------------------- /src/common/utils/types.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-types 2 | export type DeepPartial = T extends object ? { 3 | [P in keyof T]?: DeepPartial; 4 | } : T; 5 | 6 | export type WithRequired = T & { [P in K]-?: T[P] }; 7 | -------------------------------------------------------------------------------- /src/architect/component/component.entity.ts: -------------------------------------------------------------------------------- 1 | import Account from '../account/account.entity'; 2 | 3 | export interface Component { 4 | id: string; 5 | created_at: string; 6 | updated_at: string; 7 | name: string; 8 | account: Account; 9 | component_id: string; 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine 2 | 3 | # RUN npm install -g @architect-io/cli@rc 4 | 5 | WORKDIR /usr/src/app 6 | 7 | COPY .npmrc package*.json tsconfig.json ./ 8 | RUN npm install 9 | 10 | COPY bin bin 11 | COPY src src 12 | 13 | RUN npm link . 14 | 15 | ENTRYPOINT [ "architect" ] 16 | -------------------------------------------------------------------------------- /test/integration/hello-world/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | # added to support CI job 4 | RUN apk update && apk upgrade && \ 5 | apk add --no-cache curl 6 | 7 | WORKDIR /usr/src/app 8 | 9 | COPY package.json ./ 10 | RUN npm i 11 | COPY . . 12 | 13 | CMD [ "npm", "start" ] 14 | -------------------------------------------------------------------------------- /test/webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-test", 3 | "version": "0.1.0", 4 | "devDependencies": { 5 | "webpack": "^4.46.0", 6 | "webpack-bundle-analyzer": "^4.7.0", 7 | "webpack-cli": "^4.10.0" 8 | }, 9 | "scripts": { 10 | "webpack": "webpack" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/common/docker-compose/project.ts: -------------------------------------------------------------------------------- 1 | export type DockerComposeProject = { 2 | Name: string; 3 | Status: string; 4 | ConfigFiles: string | undefined; 5 | }; 6 | 7 | export type DockerComposeProjectWithConfig = { 8 | Name: string; 9 | Status: string; 10 | ConfigFiles: string; 11 | }; 12 | -------------------------------------------------------------------------------- /test/mocks/ingress/downstream/architect.yml: -------------------------------------------------------------------------------- 1 | name: tests/ingress-downstream 2 | 3 | services: 4 | downstream-service: 5 | image: ellerbrock/alpine-bash-curl-ssl 6 | interfaces: 7 | main: 8080 8 | 9 | interfaces: 10 | downstream: ${{ services.downstream-service.interfaces.main.url }} 11 | -------------------------------------------------------------------------------- /src/common/errors/login-required.ts: -------------------------------------------------------------------------------- 1 | import { ArchitectError } from '../../'; 2 | 3 | export default class LoginRequiredError extends ArchitectError { 4 | constructor() { 5 | super(); 6 | this.name = 'login_required'; 7 | this.message = 'Please login by running `architect login`'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/traefik.yaml: -------------------------------------------------------------------------------- 1 | tls: 2 | stores: 3 | default: 4 | defaultCertificate: 5 | certFile: /etc/traefik-ssl/fullchain.pem 6 | keyFile: /etc/traefik-ssl/privkey.pem 7 | certificates: 8 | - certFile: /etc/traefik-ssl/fullchain.pem 9 | keyFile: /etc/traefik-ssl/privkey.pem 10 | -------------------------------------------------------------------------------- /src/dependency-manager/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type RecursivePartial = { 2 | [P in keyof T]?: RecursivePartial; 3 | }; 4 | 5 | // a typing for the raw result of js-yaml.load(); 6 | // eslint-disable-next-line @typescript-eslint/ban-types 7 | export type ParsedYaml = object | string | number | null | undefined; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | /.nyc_output 4 | /dist 5 | /lib 6 | /tmp 7 | node_modules 8 | .test 9 | .idea 10 | architect_services 11 | oclif.manifest.json 12 | *.env 13 | .DS_Store 14 | tsconfig.tsbuildinfo 15 | creds.json 16 | test/sentry-history.json 17 | .npmrc 18 | /test/webpack/lib 19 | posthog.json 20 | -------------------------------------------------------------------------------- /test/integration/stateful-component/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | # added to support superset liveness probe 4 | RUN apk update && apk upgrade && \ 5 | apk add --no-cache curl 6 | 7 | WORKDIR /usr/src/app 8 | 9 | COPY package*.json ./ 10 | RUN npm install 11 | COPY . . 12 | 13 | CMD [ "npm", "start" ] 14 | -------------------------------------------------------------------------------- /src/common/utils/offline.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | export class OfflineUtils { 4 | // indicates that the client may be offline, useful for ignoring optional calls 5 | public static indicates_offline(err: AxiosError): boolean { 6 | return err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/integration/stateless-component/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine 2 | 3 | # added to support CI job 4 | RUN apk update && apk upgrade && \ 5 | apk add --no-cache curl 6 | 7 | WORKDIR /usr/src/app 8 | 9 | COPY package*.json ./ 10 | RUN npm install 11 | COPY . . 12 | RUN npm run build 13 | 14 | CMD [ "npm", "run", "start" ] 15 | -------------------------------------------------------------------------------- /src/architect/component/component-version.entity.ts: -------------------------------------------------------------------------------- 1 | import { ComponentConfig } from '../../dependency-manager/config/component-config'; 2 | import { Component } from './component.entity'; 3 | 4 | export interface ComponentVersion { 5 | created_at: string; 6 | tag: string; 7 | config: ComponentConfig; 8 | component: Component; 9 | } 10 | -------------------------------------------------------------------------------- /test/integration/stateful-component/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | # added to support CI job 4 | RUN apk update && apk upgrade && \ 5 | apk add --no-cache curl 6 | 7 | WORKDIR /usr/src/app 8 | 9 | COPY package*.json ./ 10 | RUN npm install 11 | COPY . . 12 | RUN npm run build 13 | 14 | CMD [ "npm", "run", "start" ] 15 | -------------------------------------------------------------------------------- /test/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "log_level": "debug", 3 | "registry_host": "mock.registry.localhost", 4 | "api_host": "http://mock.api.localhost", 5 | "app_host": "http://mock.app.localhost", 6 | "oauth_host": "http://mock.auth0.localhost", 7 | "oauth_client_id": "mock_client_id", 8 | "analytics_id": "a444431a-c473-4a50-a8e4-6c08552fe20e" 9 | } 10 | -------------------------------------------------------------------------------- /src/common/errors/invalid-config-option.ts: -------------------------------------------------------------------------------- 1 | import { ArchitectError } from '../../'; 2 | 3 | export default class InvalidConfigOption extends ArchitectError { 4 | constructor(option: string) { 5 | super(); 6 | this.name = 'invalid_config_option'; 7 | this.message = `The CLI config option, "${option}", is not a valid option.`; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/common/errors/missing-build-context.ts: -------------------------------------------------------------------------------- 1 | import { ArchitectError } from '../../'; 2 | 3 | export default class MissingContextError extends ArchitectError { 4 | constructor() { 5 | super(); 6 | this.name = 'missing_build_context'; 7 | this.message = 'No context was provided. Please specify a path to a valid Architect component.'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/integration/stateless-component/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicRuntimeConfig: { 3 | ECHO_ADDR: process.env.ECHO_ADDR, 4 | NODE_ENV: process.env.NODE_ENV, 5 | }, 6 | webpack(config) { 7 | config.module.rules.push({ 8 | test: /\.svg$/, 9 | use: ['@svgr/webpack'], 10 | }); 11 | 12 | return config; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "importOrderParserPlugins": [ 7 | "typescript", 8 | "decorators-legacy" 9 | ], 10 | "importOrder": [ 11 | "^[./]" 12 | ], 13 | "jsxBracketSameLine": false, 14 | "arrowParens": "avoid", 15 | "requirePragma": true, 16 | "semi": true 17 | } 18 | -------------------------------------------------------------------------------- /src/base-table.ts: -------------------------------------------------------------------------------- 1 | import CliTable3 from 'cli-table3'; 2 | import Table from 'cli-table3'; 3 | 4 | const default_style: CliTable3.TableConstructorOptions = { 5 | style: { 6 | head: ['green'], 7 | }, 8 | }; 9 | 10 | export default class BaseTable extends Table { 11 | constructor(opts: CliTable3.TableConstructorOptions) { 12 | super({ ...default_style, ...opts }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/integration/hello-world/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-js-sample", 3 | "version": "0.1.0", 4 | "description": "A sample Node.js app using Express 4", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "dependencies": { 10 | "express": "^4.0.0" 11 | }, 12 | "engines": { 13 | "node": "17.9.1" 14 | }, 15 | "license": "MIT" 16 | } 17 | -------------------------------------------------------------------------------- /test/mocks/register/architect.yml: -------------------------------------------------------------------------------- 1 | name: invalid-account/hello-world 2 | description: Test invalid account in register command 3 | 4 | services: 5 | api: 6 | build: 7 | context: . 8 | interfaces: 9 | main: 3000 10 | optional: 11 | enabled: false 12 | 13 | interfaces: 14 | hello: 15 | url: ${{ services.api.interfaces.main.url }} 16 | ingress: 17 | subdomain: hello 18 | -------------------------------------------------------------------------------- /src/commands/platforms/index.ts: -------------------------------------------------------------------------------- 1 | import Clusters from '../clusters'; 2 | 3 | export default class Platforms extends Clusters { 4 | static aliases = ['platform', 'platform:search', 'platforms', 'platforms:search']; 5 | static state = 'deprecated'; 6 | static deprecationOptions = { 7 | to: 'clusters', 8 | }; 9 | static hidden = true; 10 | 11 | async run(): Promise { 12 | await super.run(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/integration/stateless-component/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /test/integration/stateful-component/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /src/architect/pipeline/pipeline.entity.ts: -------------------------------------------------------------------------------- 1 | import Account from '../account/account.entity'; 2 | import Cluster from '../cluster/cluster.entity'; 3 | 4 | export default interface Pipeline { 5 | id: string; 6 | failed_at?: string; 7 | applied_at?: string; 8 | aborted_at?: string; 9 | environment?: { 10 | id: string; 11 | name: string; 12 | cluster: Cluster; 13 | account: Account; 14 | }; 15 | cluster?: Cluster; 16 | } 17 | -------------------------------------------------------------------------------- /test/integration/hello-world/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | 4 | app.set('port', (process.env.PORT || 8080)); 5 | app.use(express.static(__dirname + '/public')); 6 | 7 | app.get('/', (request, response) => { 8 | response.send(`Hello ${process.env.WORLD_TEXT}!`); 9 | }); 10 | 11 | app.listen(app.get('port'), () => { 12 | console.log(`Node app is running at localhost: ${app.get('port')}`); 13 | }); 14 | -------------------------------------------------------------------------------- /src/commands/platforms/create.ts: -------------------------------------------------------------------------------- 1 | import ClusterCreate from '../clusters/create'; 2 | 3 | export default class PlatformCreate extends ClusterCreate { 4 | static aliases = ['platforms:register', 'platform:create', 'platforms:create']; 5 | static state = 'deprecated'; 6 | static deprecationOptions = { 7 | to: 'clusters:register', 8 | }; 9 | static hidden = true; 10 | 11 | async run(): Promise { 12 | await super.run(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/platforms/destroy.ts: -------------------------------------------------------------------------------- 1 | import ClusterDestroy from '../clusters/destroy'; 2 | 3 | export default class PlatformDestroy extends ClusterDestroy { 4 | static aliases = ['platforms:deregister', 'platform:destroy', 'platforms:destroy']; 5 | static state = 'deprecated'; 6 | static deprecationOptions = { 7 | to: 'clusters:deregister', 8 | }; 9 | static hidden = true; 10 | 11 | async run(): Promise { 12 | await super.run(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/architect/cluster/cluster.entity.ts: -------------------------------------------------------------------------------- 1 | import Account from '../account/account.entity'; 2 | 3 | interface Token { 4 | access_token: string; 5 | } 6 | 7 | export default interface Cluster { 8 | id: string; 9 | name: string; 10 | type: string; 11 | account: Account; 12 | token: Token; 13 | properties: { 14 | is_shared?: boolean; 15 | is_managed?: boolean; 16 | is_local?: boolean; 17 | host?: string; 18 | traefik_version: string; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /test/commands/config/get.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test'; 2 | import { mockArchitectAuth } from '../../utils/mocks'; 3 | 4 | describe('config:get', function () { 5 | 6 | const print = false; 7 | 8 | mockArchitectAuth() 9 | .stdout({ print }) 10 | .stderr({ print }) 11 | .command(['config:get', 'log_level']) 12 | .timeout(20000) 13 | .it('config:get log_level', ctx => { 14 | expect(ctx.stdout).to.contain('debug') 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/commands/whoami.ts: -------------------------------------------------------------------------------- 1 | import BaseCommand from '../base-command'; 2 | 3 | export default class WhoAmI extends BaseCommand { 4 | static aliases = ['whoami']; 5 | static description = 'Get the logged in user'; 6 | static examples = [ 7 | 'architect whoami', 8 | ]; 9 | async auth_required(): Promise { 10 | return true; 11 | } 12 | 13 | async run(): Promise { 14 | this.log((await this.app.auth.getPersistedTokenJSON())?.email); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/common/utils/secret/helper.ts: -------------------------------------------------------------------------------- 1 | import { isNumberString } from 'class-validator'; 2 | 3 | export const parseEnvironmentVariable = (secret_value: string | undefined): (string | number | null) => { 4 | if (!secret_value) { 5 | return null; 6 | } 7 | 8 | let value: string | number = secret_value; 9 | if (value && isNumberString(value)) { 10 | value = Number.parseFloat(value); 11 | } 12 | if (`${value}`.length !== secret_value?.length) { 13 | value = secret_value; 14 | } 15 | return value; 16 | }; 17 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/state.ts: -------------------------------------------------------------------------------- 1 | export interface DependencyStateChange { 2 | type: string; 3 | key?: string; 4 | before: any; 5 | after: any; 6 | action: ('create' | 'delete' | 'update' | 'no-op'); 7 | } 8 | 9 | export interface DependencyState { 10 | action: ('create' | 'delete' | 'update' | 'no-op'); 11 | applied_at?: Date; 12 | failed_at?: Date; 13 | started_at?: Date; 14 | changes: DependencyStateChange[]; 15 | healthy_replicas: number; 16 | replicas: number; 17 | warnings: string[]; 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/init/check-version.ts: -------------------------------------------------------------------------------- 1 | import { Hook } from '@oclif/core'; 2 | import semver from 'semver'; 3 | 4 | const hook: Hook<'init'> = async function (_) { 5 | // eslint-disable-next-line unicorn/prefer-module 6 | const version = require('../../../package').engines.node; 7 | if (!semver.satisfies(process.version, version)) { 8 | this.error(`The required node version ${version} for the Architect CLI is not satisfied by your current node version ${process.version}.`); 9 | } 10 | }; 11 | 12 | export default hook; 13 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Hide ExperimentalWarnings that appear from oclif when users are running earlier version of node 18 4 | // https://github.com/netlify/cli/issues/4608#issuecomment-1452541908 5 | process.removeAllListeners('warning'); 6 | process.on('warning', (l) => { 7 | if (l.name !== 'ExperimentalWarning') { 8 | console.warn(l); 9 | } 10 | }); 11 | 12 | require('reflect-metadata') 13 | 14 | const oclif = require('@oclif/core') 15 | 16 | oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')) 17 | -------------------------------------------------------------------------------- /test/mocks/ingress/upstream/architect.yml: -------------------------------------------------------------------------------- 1 | name: tests/ingress-upstream 2 | 3 | dependencies: 4 | tests/ingress-downstream: latest 5 | 6 | services: 7 | upstream-service: 8 | image: ellerbrock/alpine-bash-curl-ssl 9 | environment: 10 | SUB_ADDR: ${{ dependencies.tests/ingress-downstream.interfaces.downstream.url }} 11 | EXT_SUB_ADDR: ${{ dependencies.tests/ingress-downstream.ingresses.downstream.url }} 12 | interfaces: 13 | main: 8080 14 | 15 | interfaces: 16 | upstream: ${{ services.upstream-service.interfaces.main.url }} 17 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('reflect-metadata') 4 | 5 | const oclif = require('@oclif/core') 6 | const path = require('path') 7 | const project = path.join(__dirname, '..', 'tsconfig.json') 8 | 9 | // In dev mode -> use ts-node and dev plugins 10 | process.env.NODE_ENV = 'development' 11 | 12 | require('ts-node').register({ 13 | project, 14 | transpileOnly: true, 15 | }) 16 | 17 | // In dev mode, always show stack traces 18 | oclif.settings.debug = true; 19 | 20 | // Start the CLI 21 | oclif.run().then(oclif.flush).catch(oclif.Errors.handle) 22 | -------------------------------------------------------------------------------- /src/paths.ts: -------------------------------------------------------------------------------- 1 | export default class LocalPaths { 2 | static CLI_CONFIG_FILENAME = 'config.json'; 3 | static LINKED_COMPONENT_MAP_FILENAME = 'linked-components.json'; 4 | static LOCAL_DEPLOY_PATH = 'docker-compose'; 5 | static SENTRY_FILENAME = 'sentry-history.json'; 6 | static POSTHOG_PROPERTIES = 'posthog.json'; 7 | static GITHUB_TEMPLATE_CONFIG_URL = 'https://raw.githubusercontent.com/architect-team/template-configs/main/config.json'; 8 | // eslint-disable-next-line unicorn/prefer-module 9 | static SENTRY_ROOT_PATH = __dirname || process.cwd(); 10 | } 11 | -------------------------------------------------------------------------------- /test/mocks/buildpack/buildpack-architect.yml: -------------------------------------------------------------------------------- 1 | name: hello-world 2 | 3 | description: Test registering a component using buildpack 4 | 5 | secrets: 6 | world_text: 7 | default: World 8 | 9 | services: 10 | api: 11 | build: 12 | context: ../../integration/hello-world/ 13 | buildpack: true 14 | interfaces: 15 | hello: 16 | port: 3000 17 | ingress: 18 | subdomain: hello 19 | liveness_probe: 20 | command: curl --fail localhost:3000 21 | environment: 22 | WORLD_TEXT: ${{ secrets.world_text }} 23 | -------------------------------------------------------------------------------- /src/dependency-manager/deprecated-spec/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ComponentConfig } from '../config/component-config'; 3 | import { DependencyGraph } from '../graph'; 4 | import type DependencyManager from '../manager'; 5 | 6 | export abstract class DeprecatedSpec { 7 | protected manager: DependencyManager; 8 | constructor(manager: DependencyManager) { 9 | this.manager = manager; 10 | } 11 | 12 | public abstract shouldRun(component_configs: ComponentConfig[]): boolean; 13 | public abstract transformGraph(graph: DependencyGraph, component_configs: ComponentConfig[]): void; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/init/tty.ts: -------------------------------------------------------------------------------- 1 | import { Hook } from '@oclif/core'; 2 | import readline from 'readline'; 3 | import PromptUtils from '../../common/utils/prompt-utils'; 4 | 5 | const hook: Hook<'init'> = async function (_) { 6 | // https://github.com/SBoudrias/Inquirer.js#know-issues 7 | if (/^win/i.test(process.platform)) { 8 | // eslint-disable-next-line @typescript-eslint/no-empty-function 9 | readline.Interface.prototype.close = () => { }; 10 | } 11 | 12 | if (!PromptUtils.promptsAvailable()) { 13 | PromptUtils.disablePrompts(); 14 | } 15 | }; 16 | 17 | export default hook; 18 | -------------------------------------------------------------------------------- /test/integration/stateful-component/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@architect-examples/stateful-component-backend", 3 | "version": "0.1.0", 4 | "description": "stateful component backend application", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node src/index.js" 8 | }, 9 | "keywords": [], 10 | "author": "ryan-cahill", 11 | "license": "ISC", 12 | "dependencies": { 13 | "body-parser": "^1.20.1", 14 | "cors": "2.8.5", 15 | "express": "^4.18.2", 16 | "pg": "^8.8.0", 17 | "sequelize": "^6.25.3", 18 | "winston": "^3.8.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/common/docker/cmd.ts: -------------------------------------------------------------------------------- 1 | import execa, { Options } from 'execa'; 2 | import { DockerHelper } from './helper'; 3 | 4 | // eslint-disable-next-line unicorn/no-object-as-default-parameter 5 | export const docker = async (args: string[], opts = { stdout: true }, execa_opts?: Options): Promise => { 6 | if (process.env.TEST === '1') { 7 | return; 8 | } 9 | 10 | DockerHelper.verifyDocker(); 11 | 12 | const cmd = execa('docker', args, execa_opts); 13 | if (opts.stdout) { 14 | cmd.stdout?.pipe(process.stdout); 15 | cmd.stderr?.pipe(process.stderr); 16 | } 17 | return cmd; 18 | }; 19 | -------------------------------------------------------------------------------- /test/integration/stateless-component/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@architect-examples/stateless-component", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "start": "NODE_ENV=production node src/server.js" 8 | }, 9 | "dependencies": { 10 | "@material-ui/core": "^4.12.4", 11 | "@material-ui/styles": "^4.11.5", 12 | "@svgr/webpack": "^6.5.1", 13 | "axios": "^1.1.3", 14 | "express": "^4.17.1", 15 | "moment": "^2.24.0", 16 | "next": "^12.3.1", 17 | "react": "^17.0.2", 18 | "react-dom": "^17.0.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/integration/stateful-component/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@architect-examples/stateful-component", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "start": "NODE_ENV=production node src/server.js" 8 | }, 9 | "dependencies": { 10 | "@material-ui/core": "^4.12.4", 11 | "@material-ui/styles": "^4.11.5", 12 | "@svgr/webpack": "^6.5.1", 13 | "axios": "^1.1.3", 14 | "express": "^4.17.1", 15 | "moment": "^2.24.0", 16 | "next": "^12.3.1", 17 | "react": "^17.0.2", 18 | "react-dom": "^17.0.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/mocks/register/nonexistence-dockerfile-architect.yml: -------------------------------------------------------------------------------- 1 | name: hello-world 2 | 3 | description: Test registering a component using a dockerfile that does not exist 4 | 5 | secrets: 6 | world_text: 7 | default: World 8 | 9 | services: 10 | api: 11 | build: 12 | context: ../../integration/hello-world 13 | dockerfile: nonexistent-dockerfile 14 | interfaces: 15 | hello: 16 | port: 3000 17 | ingress: 18 | subdomain: hello 19 | liveness_probe: 20 | command: curl --fail localhost:3000 21 | environment: 22 | WORLD_TEXT: ${{ secrets.world_text }} 23 | -------------------------------------------------------------------------------- /src/dependency-manager/generate.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import { getArchitectJSONSchema } from './spec/utils/json-schema'; 4 | import { simpleDocs } from './spec/utils/spec-docs'; 5 | 6 | // eslint-disable-next-line unicorn/prefer-module 7 | const output_path = path.join(__dirname, './schema/architect.schema.json'); 8 | fs.writeJSONSync(output_path, getArchitectJSONSchema(), { spaces: 2 }); 9 | 10 | // use the schema to generate markdown docs and write them to our docs directory 11 | const markdown = simpleDocs(getArchitectJSONSchema()); 12 | fs.writeFileSync('./architect-yml.md', markdown); 13 | -------------------------------------------------------------------------------- /src/common/utils/oras.ts: -------------------------------------------------------------------------------- 1 | import execa, { Options } from 'execa'; 2 | import which from 'which'; 3 | 4 | export const oras = async (args: string[], options?: Options): Promise => { 5 | const cmd = execa('oras', args, options); 6 | 7 | cmd.stdout?.pipe(process.stdout); 8 | cmd.stderr?.pipe(process.stderr); 9 | 10 | try { 11 | return await cmd; 12 | } catch (err) { 13 | try { 14 | which.sync('oras'); 15 | } catch { 16 | throw new Error('Architect requires oras to be installed for custom modules.\nhttps://github.com/deislabs/oras#cli-installation'); 17 | } 18 | throw err; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/common/docker/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import { ArchitectError } from '../../dependency-manager/utils/errors'; 4 | 5 | export class DockerUtils { 6 | public static doesDockerfileExist(context: string, dockerfile: string | undefined): boolean { 7 | if (!dockerfile) { 8 | return fs.existsSync(path.join(context, 'Dockerfile')); 9 | } 10 | 11 | if (!fs.existsSync(path.join(context, dockerfile))) { 12 | throw new ArchitectError(`${path.join(context, dockerfile)} does not exist. Please verify the correct context and/or dockerfile were given.`); 13 | } 14 | return true; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/commands/link/list.ts: -------------------------------------------------------------------------------- 1 | import BaseCommand from '../../base-command'; 2 | import BaseTable from '../../base-table'; 3 | 4 | export default class ListLinkedComponents extends BaseCommand { 5 | async auth_required(): Promise { 6 | return false; 7 | } 8 | 9 | static description = 'List all linked components.'; 10 | static examples = [ 11 | 'architect link:list', 12 | ]; 13 | async run(): Promise { 14 | const table = new BaseTable({ head: ['Component', 'Path'] }); 15 | for (const entry of Object.entries(this.app.linkedComponents)) { 16 | table.push(entry); 17 | } 18 | this.log(table.toString()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/dependency-manager/utils/regex.ts: -------------------------------------------------------------------------------- 1 | export const matches = (text: string, pattern: RegExp): { [Symbol.iterator]: () => Generator; } => ({ 2 | [Symbol.iterator]: function* () { 3 | const clone = new RegExp(pattern.source, pattern.flags); 4 | let match = null; 5 | do { 6 | match = clone.exec(text); 7 | if (match) { 8 | yield match; 9 | clone.lastIndex = match.index + 1; // Support overlapping match groups 10 | } 11 | } while (match); 12 | }, 13 | }); 14 | 15 | export function escapeRegex(string: string): string { 16 | return string.replace(/[$()*+./?[\\\]^{|}-]/g, '\\$&'); 17 | } 18 | -------------------------------------------------------------------------------- /test/mocks/examples/hello-world.architect.yml: -------------------------------------------------------------------------------- 1 | name: hello-world 2 | description: A simple hello-world component that returns "Hello World!" on every request. 3 | homepage: https://github.com/architect-team/architect-cli/tree/main/examples/hello-world 4 | keywords: 5 | - hello-world 6 | - nodejs 7 | - architect 8 | - examples 9 | 10 | secrets: 11 | world_text: 12 | default: World 13 | 14 | services: 15 | api: 16 | build: 17 | context: ../../integration/hello-world 18 | interfaces: 19 | main: 20 | port: 8080 21 | ingress: 22 | subdomain: hello 23 | environment: 24 | WORLD_TEXT: ${{ secrets.world_text }} 25 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/transform/task-transform.ts: -------------------------------------------------------------------------------- 1 | import { TaskConfig } from '../../config/task-config'; 2 | import { ComponentInstanceMetadata } from '../component-spec'; 3 | import { TaskSpec } from '../task-spec'; 4 | import { transformResourceSpec } from './resource-transform'; 5 | 6 | export const transformTaskSpec = (key: string, spec: TaskSpec, metadata: ComponentInstanceMetadata): TaskConfig => { 7 | const resource_config = transformResourceSpec('tasks', key, spec, metadata); 8 | 9 | return { 10 | ...resource_config, 11 | debug: spec.debug ? transformTaskSpec(key, spec.debug, metadata) : undefined, 12 | schedule: spec.schedule, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/node/gateway.ts: -------------------------------------------------------------------------------- 1 | import { DependencyNode } from '.'; 2 | 3 | export class GatewayNode extends DependencyNode { 4 | __type = 'gateway'; 5 | 6 | host: string; 7 | port: number; 8 | 9 | static getRef(port: number): string { 10 | return port === 80 || port === 443 ? 'gateway' : `gateway-${port}`; 11 | } 12 | 13 | constructor(host: string, port = 80) { 14 | super(); 15 | this.host = host; 16 | this.port = port; 17 | } 18 | 19 | get ref(): string { 20 | return GatewayNode.getRef(this.port); 21 | } 22 | 23 | get interfaces(): { [key: string]: any } { 24 | return { _default: { port: this.port } }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/architect/user/user.utils.ts: -------------------------------------------------------------------------------- 1 | import AppService from '../../app-config/service'; 2 | import Account from '../account/account.entity'; 3 | import User from './user.entity'; 4 | 5 | export default interface Membership { 6 | id: string; 7 | user: User; 8 | account: Account; 9 | role: string; 10 | } 11 | 12 | export default class UserUtils { 13 | static async isAdmin(app: AppService, account_id: string): Promise { 14 | const { data: user } = await app.api.get('/users/me'); 15 | const membership = user.memberships?.find((membership: Membership) => membership.account.id === account_id); 16 | return Boolean(membership) && membership.role !== 'MEMBER'; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/dependency-manager/spec/partials/README.md: -------------------------------------------------------------------------------- 1 | # Component Partial Unit Tests 2 | 3 | - all files in this directory should be YML files that represent partial ComponentSpecs 4 | - the directory structure should mirror the ComponentSpec structure, we dynamically load and merge these partials in automated tests 5 | - a directory name is merged as a key inline with its parent partial 6 | - a directory name that begins with _ is merged one level deep into its parent partial 7 | - adding a partial component spec to this directory will analyze it with every other combination of merged ComponentSpecs 8 | - no interpolation is performed here so interpolation strings are not being validated (for obvious reasons) 9 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | const { ESLint } = require('eslint'); 2 | 3 | const removeIgnoredFiles = async (files) => { 4 | const eslint = new ESLint(); 5 | const ignoredFiles = await Promise.all(files.map((file) => eslint.isPathIgnored(file))); 6 | const filteredFiles = files.filter((_, i) => !ignoredFiles[i]); 7 | return filteredFiles.join(' '); 8 | }; 9 | 10 | module.exports = { 11 | '*.{js,ts}': async (files) => { 12 | const filesToLint = await removeIgnoredFiles(files); 13 | return [ 14 | `prettier --write ${files.join(' ')}`, 15 | `eslint --fix --max-warnings=0 ${filesToLint}`, 16 | ]; 17 | }, 18 | '*.{js,ts,__parallel1__}': ['npm run check-circular'], 19 | }; 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | A short description of what this PR intends to accomplish. This should proivde context as to why these changes are necessary. 3 | 4 | 5 | ## Changes 6 | Provide a list of any changes that that have been made to accomplish the goal stated above. 7 | 8 | 9 | ## Tests 10 | Provide a list of steps that can be used to test each of your changes. Any special setup should be called out explicitly. Examples of test commands or URLs can help when dealing with a CLI or API. 11 | 12 | 13 | 14 | ## Pictures 15 | If any UX has changed a picture should be attached to show the new user experience. Any CLI tooling changes count as a UX change. 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "emitDecoratorMetadata": true, 5 | "declaration": true, 6 | "importHelpers": true, 7 | "esModuleInterop": true, 8 | "sourceMap": true, 9 | "module": "commonjs", 10 | "outDir": "lib", 11 | "rootDir": "src", 12 | "strict": true, 13 | "resolveJsonModule": true, 14 | "target": "es2019", 15 | "composite": true, 16 | "noEmitHelpers": true, 17 | "plugins": [ 18 | { 19 | "transform": "typescript-json/lib/transform" 20 | } 21 | ], 22 | "skipLibCheck": true 23 | }, 24 | "include": ["./src/**/*"], 25 | "exclude": ["node_modules", "test", "lib"] 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/logout.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import BaseCommand from '../base-command'; 3 | import { RequiresDocker } from '../common/docker/helper'; 4 | 5 | export default class Logout extends BaseCommand { 6 | async auth_required(): Promise { 7 | return false; 8 | } 9 | 10 | static description = 'Logout from the Architect registry'; 11 | static examples = [ 12 | 'architect logout', 13 | ]; 14 | static flags = { ...BaseCommand.flags }; 15 | 16 | @RequiresDocker() // docker is required for logout because we run `docker logout` 17 | async run(): Promise { 18 | await this.app.auth.logout(); 19 | this.log(chalk.green('Logout successful')); 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/edge/index.ts: -------------------------------------------------------------------------------- 1 | import { DependencyState } from '../state'; 2 | 3 | export abstract class DependencyEdge { 4 | abstract __type: string; 5 | 6 | from: string; 7 | to: string; 8 | interface_to: string; 9 | 10 | state?: DependencyState; 11 | 12 | constructor(from: string, to: string, interface_to: string) { 13 | this.from = from; 14 | this.to = to; 15 | this.interface_to = interface_to; 16 | } 17 | 18 | instance_id = ''; 19 | 20 | toString(): string { 21 | return `${this.__type}: ${this.from} -> ${this.to}[${this.interface_to}]`; 22 | } 23 | 24 | get ref(): string { 25 | return `${this.__type}.${this.from}.${this.to}.${this.interface_to}`; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/integration/scheduled-tasks/architect.yml: -------------------------------------------------------------------------------- 1 | name: scheduled-tasks 2 | description: Example application that supports manual tasks and prints the results 3 | homepage: https://github.com/architect-team/architect-cli/tree/main/examples/scheduled-tasks 4 | 5 | tasks: 6 | curler: 7 | build: 8 | context: . 9 | schedule: '0 0 * * *' # every day at 12:00am 10 | command: 11 | - sh 12 | - -c 13 | - curl $SERVER_URL 14 | 15 | environment: 16 | SERVER_URL: ${{ services.api.interfaces.main.url }} 17 | 18 | services: 19 | api: 20 | build: 21 | context: ../hello-world 22 | interfaces: 23 | main: 24 | port: 3000 25 | ingress: 26 | subdomain: api 27 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/node/index.ts: -------------------------------------------------------------------------------- 1 | import { ServiceInterfaceConfig } from '../../config/service-config'; 2 | import { Dictionary } from '../../utils/dictionary'; 3 | import { DependencyState } from '../state'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 6 | export interface DependencyNodeOptions { } 7 | 8 | export abstract class DependencyNode implements DependencyNodeOptions { 9 | abstract __type: string; 10 | 11 | state?: DependencyState; 12 | 13 | abstract ref: string; 14 | 15 | abstract get interfaces(): Dictionary; 16 | 17 | instance_id = ''; 18 | 19 | deployment_id?: string; 20 | 21 | get is_external(): boolean { 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/dependency-manager/config/common-config.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface LivenessProbeConfig { 3 | success_threshold: number | string; // TODO:290:number 4 | failure_threshold: number | string; // TODO:290:number 5 | timeout: string; 6 | interval: string; 7 | initial_delay: string; 8 | path?: string; // deprecated 9 | command?: string[]; 10 | port?: number | string; // deprecated 11 | } 12 | 13 | // Though VolumeConfig is only used in the ServiceConfig, it's expected that this 14 | // config object can and will be used in other resources in the future. 15 | export interface VolumeConfig { 16 | mount_path?: string; 17 | host_path?: string; 18 | key?: string; 19 | description?: string; 20 | readonly?: boolean | string; 21 | } 22 | -------------------------------------------------------------------------------- /src/architect/deployment/deployment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from '../../'; 2 | import Account from '../account/account.entity'; 3 | import Pipeline from '../pipeline/pipeline.entity'; 4 | 5 | export default interface Deployment { 6 | id: string; 7 | instance_id: string; 8 | applied_at?: string; 9 | failed_at?: string; 10 | aborted_at?: string; 11 | type: string; 12 | action: string; 13 | metadata: { 14 | instance_name?: string; 15 | } 16 | component_version?: { 17 | name: string; 18 | tag: string; 19 | component: { 20 | name: string; 21 | account: Account; 22 | }; 23 | config: { 24 | name: string; 25 | services?: Dictionary 26 | }; 27 | }; 28 | pipeline: Pipeline; 29 | } 30 | -------------------------------------------------------------------------------- /test/mocks/deprecations/liveness-probe-path-port.architect.yml: -------------------------------------------------------------------------------- 1 | name: hello-world 2 | description: A simple hello-world component that returns "Hello World!" on every request. 3 | homepage: https://github.com/architect-team/architect-cli/tree/main/examples/hello-world 4 | keywords: 5 | - hello-world 6 | - nodejs 7 | - architect 8 | - examples 9 | 10 | secrets: 11 | world_text: 12 | default: World 13 | 14 | services: 15 | api: 16 | build: 17 | context: ../../integration/hello-world 18 | interfaces: 19 | main: 20 | port: 8080 21 | ingress: 22 | subdomain: hello 23 | environment: 24 | WORLD_TEXT: ${{ secrets.world_text }} 25 | liveness_probe: 26 | port: 3000 27 | path: / 28 | -------------------------------------------------------------------------------- /test/integration/scheduled-tasks/Dockerfile: -------------------------------------------------------------------------------- 1 | # https://github.com/ellerbrock/docker-collection/blob/master/dockerfiles/alpine-bash-curl-ssl/Dockerfile 2 | FROM alpine:3.8 3 | 4 | # Optional Configuration Parameter 5 | ARG SERVICE_USER 6 | ARG SERVICE_HOME 7 | 8 | # Default Settings 9 | ENV SERVICE_USER ${SERVICE_USER:-download} 10 | ENV SERVICE_HOME ${SERVICE_HOME:-/home/${SERVICE_USER}} 11 | 12 | RUN \ 13 | adduser -h ${SERVICE_HOME} -s /sbin/nologin -u 1000 -D ${SERVICE_USER} && \ 14 | apk add --no-cache \ 15 | curl \ 16 | bash \ 17 | git \ 18 | dumb-init \ 19 | openssl 20 | 21 | USER ${SERVICE_USER} 22 | WORKDIR ${SERVICE_HOME} 23 | VOLUME ${SERVICE_HOME} 24 | 25 | ENTRYPOINT [ "/usr/bin/dumb-init", "--" ] 26 | CMD [ "curl", "--help" ] 27 | -------------------------------------------------------------------------------- /src/commands/config/view.ts: -------------------------------------------------------------------------------- 1 | import BaseCommand from '../../base-command'; 2 | import Table from '../../base-table'; 3 | 4 | export default class ConfigView extends BaseCommand { 5 | async auth_required(): Promise { 6 | return false; 7 | } 8 | 9 | static description = 'View all the CLI configuration settings'; 10 | static aliases = ['config']; 11 | static examples = [ 12 | 'architect config', 13 | ]; 14 | static flags = { 15 | ...BaseCommand.flags, 16 | }; 17 | 18 | async run(): Promise { 19 | const table = new Table({ head: ['Name', 'Value'] }); 20 | 21 | for (const entry of Object.entries(this.app.config.toJSON())) { 22 | table.push(entry); 23 | } 24 | 25 | this.log(table.toString()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/integration/hello-world/architect.yml: -------------------------------------------------------------------------------- 1 | name: hello-world 2 | description: A simple hello-world component that returns "Hello World!" on every request. 3 | homepage: https://github.com/architect-team/architect-cli/tree/main/examples/hello-world 4 | keywords: 5 | - hello-world 6 | - nodejs 7 | - architect 8 | - examples 9 | 10 | secrets: 11 | world_text: 12 | default: World 13 | 14 | services: 15 | api: 16 | build: 17 | context: . 18 | interfaces: 19 | main: 20 | port: 8080 21 | ingress: 22 | subdomain: hello 23 | private: 24 | port: 8080 25 | ingress: 26 | subdomain: hello-private 27 | private: true 28 | environment: 29 | WORLD_TEXT: ${{ secrets.world_text }} 30 | -------------------------------------------------------------------------------- /src/common/utils/localized-timestamp.ts: -------------------------------------------------------------------------------- 1 | const locale = Intl.DateTimeFormat().resolvedOptions().locale; 2 | // Following line is ts-ignored because typing for DateTimeFormatOptions was broken. 3 | // This has since been resolved in es2020, but there were other issues with mocha 4 | // after updating the target from es2017. Easiest to just ignore the check for now. 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | const format_options: Intl.DateTimeFormatOptions = { // @ts-ignore 7 | dateStyle: 'short', 8 | timeStyle: 'long', 9 | }; 10 | 11 | const localizedTimestamp = (timestamp: string): string => { 12 | const date = Date.parse(timestamp); 13 | return new Intl.DateTimeFormat(locale, format_options).format(date); 14 | }; 15 | 16 | export default localizedTimestamp; 17 | -------------------------------------------------------------------------------- /src/static/login_callback_success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |

CLI Login Successful

8 |

you can close this window now

9 |
10 |
11 | 12 | 13 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | A short description of what this PR intends to accomplish. This should proivde context as to why these changes are necessary. 3 | 4 | 5 | ## Changes 6 | Provide a list of any changes that that have been made to accomplish the goal stated above. 7 | 8 | 9 | ## Tests 10 | Provide a list of steps that can be used to test each of your changes. Any special setup should be called out explicitly. Examples of test commands or URLs can help when dealing with a CLI or API. 11 | 12 | 13 | ## Docs 14 | If this is a new feature or changes functionality, provide a link to the ticket for documentation additions or updates. 15 | 16 | 17 | ## Pictures 18 | If any UX has changed a picture should be attached to show the new user experience. Any CLI tooling changes count as a UX change. 19 | -------------------------------------------------------------------------------- /test/integration/stateful-component/backend/src/migration.js: -------------------------------------------------------------------------------- 1 | exports.runMigration = async (sequelize, logger) => { 2 | const DB_NAME = sequelize.getDatabaseName(); 3 | 4 | try { 5 | const result = await sequelize.query(`select exists(SELECT datname FROM pg_catalog.pg_database WHERE lower(datname) = lower('${DB_NAME}'));`); 6 | if (!result[0][0].exists) { 7 | logger.info('Creating database'); 8 | await sequelize.query(`CREATE DATABASE "${DB_NAME}"`); 9 | } else { 10 | logger.info('Database already exists'); 11 | } 12 | } catch (err) { 13 | logger.error(`Sequelize init failed\n${err}`); 14 | throw err; 15 | } 16 | 17 | try { 18 | await sequelize.sync(); 19 | } catch (error) { 20 | logger.error(`Sequelize sync failed\n${error}`); 21 | throw error; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /test/integration/stateless-component/src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import App from 'next/app'; 2 | import blue from '@material-ui/core/colors/blue'; 3 | import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; 4 | import { CssBaseline } from '@material-ui/core'; 5 | 6 | const theme = createMuiTheme({ 7 | palette: { 8 | type: 'dark', 9 | primary: blue, 10 | secondary: blue 11 | } 12 | }); 13 | 14 | export default ({ Component, pageProps }) => ( 15 | 16 | 17 | 18 | 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Example command '...' 16 | 2. Answers to any questions '....' 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Desktop (please complete the following information):** 23 | - OS: Windows 11 (WSL2) 24 | - Architect CLI Version: 1.15.2 25 | 26 | **NodeJS/NPM (please complete the following information):** 27 | - NodeJS Version: 1.17.8 28 | - NPM: 8 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /test/integration/stateful-component/frontend/src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import App from 'next/app'; 2 | import blue from '@material-ui/core/colors/blue'; 3 | import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; 4 | import { CssBaseline } from '@material-ui/core'; 5 | 6 | const theme = createMuiTheme({ 7 | palette: { 8 | type: 'dark', 9 | primary: blue, 10 | secondary: blue 11 | } 12 | }); 13 | 14 | export default ({ Component, pageProps }) => ( 15 | 16 | 17 | 18 | 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /patches/@oclif+core+1.19.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@oclif/core/lib/interfaces/parser.d.ts b/node_modules/@oclif/core/lib/interfaces/parser.d.ts 2 | index f533dcc..29deae0 100644 3 | --- a/node_modules/@oclif/core/lib/interfaces/parser.d.ts 4 | +++ b/node_modules/@oclif/core/lib/interfaces/parser.d.ts 5 | @@ -140,6 +140,7 @@ export declare type FlagProps = { 6 | * List of flags that cannot be used with this flag. 7 | */ 8 | exclusive?: string[]; 9 | + sensitive?: boolean; 10 | /** 11 | * Exactly one of these flags must be provided. 12 | */ 13 | @@ -159,7 +160,6 @@ export declare type FlagProps = { 14 | }; 15 | export declare type BooleanFlagProps = FlagProps & { 16 | type: 'boolean'; 17 | - allowNo: boolean; 18 | }; 19 | export declare type OptionFlagProps = FlagProps & { 20 | type: 'option'; 21 | -------------------------------------------------------------------------------- /src/dependency-manager/config/resource-config.ts: -------------------------------------------------------------------------------- 1 | import { ComponentInstanceMetadata } from '../spec/component-spec'; 2 | import { Dictionary } from '../utils/dictionary'; 3 | 4 | export interface BuildConfig { 5 | context?: string; 6 | buildpack?: boolean; 7 | args?: Dictionary; 8 | dockerfile?: string; 9 | target?: string; 10 | } 11 | 12 | export interface ResourceConfig { 13 | name: string; 14 | metadata: ComponentInstanceMetadata; 15 | description?: string; 16 | image?: string; // TODO:290: not optional 17 | command?: string[]; 18 | entrypoint?: string[]; 19 | language?: string; 20 | environment: Dictionary; 21 | build?: BuildConfig; 22 | cpu?: number | string; // TODO:290:number 23 | memory?: string; 24 | depends_on: string[]; 25 | labels: Map; 26 | reserved_name?: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/dependency-manager/utils/match.ts: -------------------------------------------------------------------------------- 1 | import leven from 'leven'; 2 | 3 | export const findPotentialMatch = (value: string, options: string[], max_distance = 15): string | undefined => { 4 | if (!value) { 5 | return; 6 | } 7 | let potential_match; 8 | let shortest_distance = Number.POSITIVE_INFINITY; 9 | const value_length = value.length; 10 | for (const option of [...options].sort()) { 11 | const option_length = option.length; 12 | // https://github.com/sindresorhus/leven/issues/14 13 | if (Math.abs(value_length - option_length) >= max_distance) { 14 | continue; 15 | } 16 | 17 | const distance = leven(value, option); 18 | if (distance < max_distance && distance <= shortest_distance) { 19 | potential_match = option; 20 | shortest_distance = distance; 21 | } 22 | } 23 | return potential_match; 24 | }; 25 | -------------------------------------------------------------------------------- /src/common/utils/oclif.ts: -------------------------------------------------------------------------------- 1 | import { Flags, Interfaces } from '@oclif/core'; 2 | 3 | export const booleanString = Flags.build({ 4 | parse: async (input, _) => { 5 | const boolean_string = input.toLowerCase(); 6 | if (['true', 'false'].includes(boolean_string)) { 7 | return boolean_string === 'true'; 8 | } else { 9 | throw new Error(`Invalid value passed to booleanString: ${input}. Must be [true or false].`); 10 | } 11 | }, 12 | default: false, 13 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 14 | // @ts-ignore 15 | _type: 'booleanstring', // Used to check if the flag is a booleanstring 16 | }); 17 | 18 | export const isBooleanStringFlag = (flag?: Interfaces.CompletableFlag): boolean => { 19 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 20 | // @ts-ignore 21 | return flag?._type === 'booleanstring'; 22 | }; 23 | -------------------------------------------------------------------------------- /src/static/login_callback_failure.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |

Login Failed

8 | 9 |

%%FAILURE_MESSAGE%%

10 |
11 |
12 | 13 | 14 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /test/integration/stateless-component/src/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const next = require('next'); 3 | const axios = require('axios'); 4 | 5 | const dev = process.env.NODE_ENV !== 'production'; 6 | const app = next({ dev }); 7 | const handle = app.getRequestHandler(); 8 | 9 | app.prepare() 10 | .then(() => { 11 | const server = express(); 12 | 13 | server.get('/hello', async (req, res) => { 14 | const { data } = await axios.get(process.env.HELLO_WORLD_ADDR); 15 | return res.send(data); 16 | }); 17 | 18 | server.all('*', (req, res) => { 19 | return handle(req, res); 20 | }); 21 | 22 | const port = process.env.PORT || 8080; 23 | server.listen(port, (err) => { 24 | if (err) throw err; 25 | console.log(`> Ready on port: ${port}`); 26 | }) 27 | }) 28 | .catch((ex) => { 29 | console.error(ex.stack); 30 | process.exit(1); 31 | }); 32 | -------------------------------------------------------------------------------- /src/common/plugins/plugin-types.ts: -------------------------------------------------------------------------------- 1 | import execa, { Options } from 'execa'; 2 | 3 | export enum PluginArchitecture { 4 | AMD64, ARM64 5 | } 6 | 7 | export enum PluginPlatform { 8 | LINUX, DARWIN, WINDOWS 9 | } 10 | 11 | export enum PluginBundleType { 12 | ZIP, TAR_GZ 13 | } 14 | 15 | export interface PluginOptions { 16 | stdout: boolean; 17 | execa_options?: Options; 18 | } 19 | 20 | export interface PluginBinary { 21 | url: string; 22 | architecture: PluginArchitecture; 23 | platform: PluginPlatform; 24 | sha256: string; 25 | bundle_type: PluginBundleType; 26 | executable_path: string; 27 | } 28 | 29 | export interface ArchitectPlugin { 30 | version: string; 31 | name: string; 32 | binaries: PluginBinary[]; 33 | setup(pluginDirectory: string, binary: PluginBinary): Promise; 34 | exec(args: string[], opts: PluginOptions): Promise | undefined>; 35 | } 36 | -------------------------------------------------------------------------------- /test/webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const TerserPlugin = require('terser-webpack-plugin'); 2 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | plugins: [ 7 | // new BundleAnalyzerPlugin(), 8 | ], 9 | optimization: { 10 | minimize: true, 11 | minimizer: [new TerserPlugin({ 12 | terserOptions: { 13 | keep_classnames: /^\w+Spec$/, 14 | compress: true, 15 | mangle: true, 16 | }, 17 | })], 18 | }, 19 | node: { 20 | fs: 'empty', 21 | net: 'empty', 22 | }, 23 | externals: { 24 | acorn: '{}', 25 | 'acorn-loose': '{ LooseParser: { BaseParser: { extend: function() {} } } }', 26 | 'fs-extra': '{}', 27 | '@oclif/core': '{}', 28 | }, 29 | entry: './test.js', 30 | output: { 31 | filename: 'main.js', 32 | path: path.resolve(__dirname, 'lib'), 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/node/task.ts: -------------------------------------------------------------------------------- 1 | import { DependencyNode, DependencyNodeOptions } from '.'; 2 | import { TaskConfig } from '../../config/task-config'; 3 | 4 | export interface TaskNodeOptions { 5 | ref: string; 6 | config: TaskConfig; 7 | local_path?: string; 8 | } 9 | 10 | export class TaskNode extends DependencyNode implements TaskNodeOptions { 11 | __type = 'task'; 12 | 13 | config!: TaskConfig; 14 | 15 | ref!: string; 16 | local_path?: string; 17 | 18 | constructor(options: TaskNodeOptions & DependencyNodeOptions) { 19 | super(); 20 | if (options) { 21 | this.ref = options.ref; 22 | this.config = options.config; 23 | this.local_path = options.local_path; 24 | } 25 | } 26 | 27 | get interfaces(): { [key: string]: any } { 28 | return {}; 29 | } 30 | 31 | get ports(): string[] { 32 | return []; 33 | } 34 | 35 | get is_external(): boolean { 36 | return false; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/dependency-manager/examples-validation.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test'; 2 | import fs from 'fs-extra'; 3 | import { buildConfigFromPath } from '../../src'; 4 | import { EXAMPLE_PROJECT_PATHS } from '../utils/mocks'; 5 | 6 | // This test validates the architect.yml file for each of our example components to ensure that none go out of date 7 | describe('example component validation', function () { 8 | 9 | describe('example components', function () { 10 | for (const example_project_path of Object.values(EXAMPLE_PROJECT_PATHS)) { 11 | if (fs.existsSync(example_project_path)) { 12 | it(`${example_project_path} passes ajv json schema validation`, async () => { 13 | const component_config = buildConfigFromPath(example_project_path); 14 | 15 | expect(component_config.metadata.file?.path).to.equal(example_project_path); 16 | expect(component_config).to.not.be.undefined; 17 | }); 18 | 19 | } 20 | } 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/mocks/superset/deprecated.architect.yml: -------------------------------------------------------------------------------- 1 | name: deprecated-hello-world 2 | description: A simple hello-world component that returns "Hello World!" on every request. 3 | homepage: https://github.com/architect-team/architect-cli/tree/main/examples/hello-world 4 | keywords: 5 | - hello-world 6 | - nodejs 7 | - architect 8 | - examples 9 | 10 | services: 11 | api: 12 | build: 13 | context: ../../integration/hello-world 14 | dockerfile: ./Dockerfile 15 | interfaces: 16 | main: 8080 17 | environment: 18 | URL: ${{ services.api.interfaces.main.url }} 19 | URL2: ${{ interfaces.hello.url }} 20 | EXT_URL: ${{ ingresses.hello.url }} 21 | EXT_URL2: ${{ environment.ingresses['deprecated-hello-world'].hello.url }} 22 | 23 | interfaces: 24 | hello: 25 | description: Connects to the hello-world service to return "Hello World!" on-demand 26 | url: ${{ services.api.interfaces.main.url }} 27 | ingress: 28 | subdomain: frontend 29 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/utils/yaml.ts: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml'; 2 | 3 | let schema: yaml.Schema | undefined; 4 | export function getYamlSchema(): yaml.Schema { 5 | if (!schema) { 6 | // Ref: https://github.com/nodeca/js-yaml/blob/2b5620ed8f03ba0df319fe7710f6d7fd44811742/test/issues/0614.js#L10 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-ignore 9 | const options = Object.assign({}, yaml.types.float.options) as yaml.TypeConstructorOptions; 10 | 11 | const old = options.construct!; 12 | options.construct = function (data: any) { 13 | const float = old(data); 14 | if (`${float}` !== data) { // Lost precision - default to string 15 | return data; 16 | } else { 17 | return float; 18 | } 19 | }; 20 | 21 | const SafeFloatType = new yaml.Type('tag:yaml.org,2002:float', options); 22 | 23 | schema = yaml.DEFAULT_SCHEMA.extend({ implicit: [SafeFloatType] }); 24 | } 25 | return schema; 26 | } 27 | -------------------------------------------------------------------------------- /test/commands/logout.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import sinon from 'sinon'; 3 | import CredentialManager from '../../src/app-config/credentials'; 4 | import * as Docker from '../../src/common/docker/cmd'; 5 | 6 | describe('logout', () => { 7 | // set to true while working on tests for easier debugging; otherwise oclif/test eats the stdout/stderr 8 | const print = false; 9 | 10 | describe('deletes local credentials', () => { 11 | const credential_spy = sinon.fake.returns(null); 12 | 13 | test 14 | .timeout(20000) 15 | .stub(Docker, 'docker', sinon.fake.returns(null)) 16 | .stub(CredentialManager.prototype, 'delete', credential_spy) 17 | .stderr({ print }) 18 | .command(['logout']) 19 | .it('delete is called with expected params', () => { 20 | expect(credential_spy.getCalls().length).to.equal(1); 21 | expect(credential_spy.firstCall.args[0]).to.equal('architect.io/token'); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/common/errors/pipeline-errors.ts: -------------------------------------------------------------------------------- 1 | export class PipelineAbortedError extends Error { 2 | constructor(deployment_id: string, deployment_link: string) { 3 | super(); 4 | 5 | this.message = `Deployment ${deployment_id} was aborted. See the deployment log for more details:\n${deployment_link}`; 6 | } 7 | } 8 | 9 | export class DeploymentFailedError extends Error { 10 | constructor(pipeline_id: string, deployment_links: string[]) { 11 | super(); 12 | 13 | const deployment_string = deployment_links.length > 1 ? 14 | `${deployment_links.length} deployments` : 15 | '1 deployment'; 16 | const listified_link_string = deployment_links.map((s: string) => `- ${s}`).join('\n'); 17 | this.message = `Pipeline ${pipeline_id} failed because ${deployment_string} failed:\n${listified_link_string}`; 18 | } 19 | } 20 | 21 | export class PollingTimeout extends Error { 22 | constructor() { 23 | super(); 24 | this.message = 'Timeout while polling the pipeline'; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/commands/login.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | 3 | describe('login', () => { 4 | 5 | // set to true while working on tests for easier debugging; otherwise oclif/test eats the stdout/stderr 6 | const print = false; 7 | 8 | test 9 | .timeout(20000) 10 | .stderr({ print }) 11 | .command(['login', '-e', 'test-email']) 12 | .catch(ctx => { 13 | expect(ctx.message).to.contain('--password flag is required in CI pipelines') 14 | }) 15 | .it('requires both email and password when not in a tty environment'); 16 | 17 | test 18 | .timeout(20000) 19 | .stderr({ print }) 20 | .command(['login']) 21 | .catch(ctx => { 22 | expect(ctx.message).to.contain('We detected that this environment does not have a prompt available. To login in a non-tty environment, please use both the user and password options: `architect login -e -p `') 23 | }) 24 | .it('browser login flow throws when not in a tty environment'); 25 | 26 | }); 27 | -------------------------------------------------------------------------------- /src/dependency-manager/utils/transform.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor, plainToInstance } from 'class-transformer'; 2 | import { Dictionary } from './dictionary'; 3 | 4 | /** 5 | * Used in conjunction with the @Transform annotation from the 'class-transformer' 6 | * library to create class structures for nested dictionaries. 7 | */ 8 | export const Dict = (typeFunction: () => ClassConstructor, options?: { key?: string }) => 9 | (dict?: Dictionary): Dictionary | undefined => { 10 | if (!dict) { 11 | return undefined; 12 | } 13 | 14 | const res = {} as Dictionary; 15 | const classConstructor = typeFunction(); 16 | for (const key of Object.keys(dict)) { 17 | let value = dict[key]; 18 | if (options && options.key && typeof value === 'string') { 19 | const new_value: any = {}; 20 | new_value[options.key] = value; 21 | value = new_value; 22 | } 23 | res[key] = plainToInstance(classConstructor, value); 24 | } 25 | return res; 26 | }; 27 | -------------------------------------------------------------------------------- /src/common/overlay/overlay-server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import http from 'http'; 3 | import path from 'path'; 4 | 5 | export class OverlayServer { 6 | listen(port: number): void { 7 | const server = http.createServer(function (req, res) { 8 | res.setHeader('Access-Control-Allow-Origin', '*'); 9 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); 10 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); 11 | 12 | try { 13 | // eslint-disable-next-line unicorn/prefer-module 14 | const file_path = path.join(__dirname, '../../static/overlay.js'); 15 | const file = fs.readFileSync(file_path); 16 | res.writeHead(200, { 'Content-Type': 'text/javascript' }); 17 | res.end(file); 18 | } catch (err) { 19 | res.writeHead(500, { 'Content-Type': 'text/plain' }); 20 | res.end('Server Error'); 21 | } 22 | }); 23 | 24 | server.on('error', err => console.log(err)); 25 | 26 | server.listen(port); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/dependency-manager/utils/dictionary.ts: -------------------------------------------------------------------------------- 1 | import { IF_EXPRESSION_REGEX } from '../spec/utils/interpolation'; 2 | 3 | export interface Dictionary { 4 | [key: string]: T; 5 | } 6 | 7 | export const transformDictionary = (transform: (key: string, value: T, ...args: any) => U, input?: Dictionary, ...args: any): Dictionary => { 8 | if (!input) { 9 | return {}; 10 | } 11 | const output: Dictionary = {}; 12 | for (const [key, value] of Object.entries(input)) { 13 | if (IF_EXPRESSION_REGEX.test(key)) { 14 | continue; 15 | } 16 | output[key] = transform(key, value, ...args); 17 | } 18 | return output; 19 | }; 20 | 21 | export const sortOnKeys = (dict: Dictionary): Dictionary => { 22 | const sorted = []; 23 | // eslint-disable-next-line guard-for-in 24 | for (const key in dict) { 25 | sorted[sorted.length] = key; 26 | } 27 | sorted.sort(); 28 | 29 | const tempDict: Dictionary = {}; 30 | for (const s of sorted) { 31 | tempDict[s] = dict[s]; 32 | } 33 | 34 | return tempDict; 35 | }; 36 | -------------------------------------------------------------------------------- /bin/readme-mdx.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { fileURLToPath } from 'url'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | const args = process.argv.slice(2); 11 | const output = args[0]; 12 | console.log(`Writing ${output}...`) 13 | if (!output) { 14 | throw new Error('No output path specified') 15 | } 16 | 17 | const readme = fs.readFileSync(path.join(__dirname, '..', 'README.md')).toString(); 18 | 19 | const readme_split = readme.split('---'); 20 | readme_split.shift(); 21 | 22 | // Remove top html since tags don't render for mintlify mdx 23 | const mdx = readme_split.join('---'); 24 | 25 | if (!mdx || mdx.length === readme.length) { 26 | throw new Error('Missing divider. Failed to convert.') 27 | } 28 | 29 | // Comments don't render for mintlify mdx 30 | function removeComments(contents) { 31 | return contents.replace(//g, '') 32 | } 33 | 34 | fs.writeFileSync(output, removeComments(mdx)); 35 | 36 | console.log(`Done`) 37 | -------------------------------------------------------------------------------- /src/commands/config/get.ts: -------------------------------------------------------------------------------- 1 | import AppConfig from '../../app-config/config'; 2 | import BaseCommand from '../../base-command'; 3 | import InvalidConfigOption from '../../common/errors/invalid-config-option'; 4 | 5 | export default class ConfigGet extends BaseCommand { 6 | async auth_required(): Promise { 7 | return false; 8 | } 9 | 10 | static description = 'Get the value of a CLI config option'; 11 | static examples = [ 12 | 'architect config:get log_level', 13 | ]; 14 | static flags = { 15 | ...BaseCommand.flags, 16 | }; 17 | 18 | static args = [{ 19 | name: 'option', 20 | required: true, 21 | description: 'Name of a config option', 22 | }]; 23 | 24 | async run(): Promise { 25 | const { args } = await this.parse(ConfigGet); 26 | 27 | if (!Object.keys(this.app.config).includes(args.option)) { 28 | throw new InvalidConfigOption(args.option); 29 | } 30 | 31 | const value = this.app.config[args.option as keyof AppConfig]; 32 | if (typeof value === 'string') { 33 | this.log(value); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/config/set.ts: -------------------------------------------------------------------------------- 1 | import BaseCommand from '../../base-command'; 2 | import InvalidConfigOption from '../../common/errors/invalid-config-option'; 3 | 4 | export default class ConfigSet extends BaseCommand { 5 | async auth_required(): Promise { 6 | return false; 7 | } 8 | 9 | static description = 'Set a new value for a CLI configuration option'; 10 | static examples = [ 11 | 'architect config:set log_level info', 12 | ]; 13 | static flags = { 14 | ...BaseCommand.flags, 15 | }; 16 | 17 | static args = [{ 18 | name: 'option', 19 | required: true, 20 | description: 'Name of a config option', 21 | }, { 22 | name: 'value', 23 | required: true, 24 | description: 'New value to assign to a config option', 25 | }]; 26 | 27 | async run(): Promise { 28 | const { args } = await this.parse(ConfigSet); 29 | 30 | if (!Object.keys(this.app.config).includes(args.option)) { 31 | throw new InvalidConfigOption(args.option); 32 | } 33 | 34 | this.app.config.set(args.option, args.value); 35 | this.app.config.save(); 36 | 37 | this.log(`Successfully updated ${args.option} to ${args.value}`); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/mocks/buildpack/buildpack-dockerfile-architect.yml: -------------------------------------------------------------------------------- 1 | name: hello-world 2 | 3 | description: Test registering a component using buildpack and dockerfile services 4 | 5 | secrets: 6 | world_text: 7 | default: World 8 | 9 | services: 10 | buildpack-api: 11 | build: 12 | context: ../../integration/hello-world/ 13 | buildpack: true 14 | interfaces: 15 | hello: 16 | port: 3000 17 | ingress: 18 | subdomain: buildpack-api 19 | liveness_probe: 20 | command: curl --fail localhost:3000 21 | environment: 22 | WORLD_TEXT: ${{ secrets.world_text }} 23 | 24 | dockerfile-api: 25 | build: 26 | context: ../../integration/hello-world/ 27 | interfaces: 28 | hello: 29 | port: 4000 30 | ingress: 31 | subdomain: dockerfile-api 32 | liveness_probe: 33 | command: curl --fail localhost:4000 34 | environment: 35 | WORLD_TEXT: ${{ secrets.world_text }} 36 | 37 | # Test docker image caching by using the same Dockerfile 38 | dockerfile-api2: 39 | build: 40 | context: ../../integration/hello-world/ 41 | 42 | # Test a third party image 43 | redis: 44 | image: redis 45 | -------------------------------------------------------------------------------- /test/integration/stateless-component/architect.yml: -------------------------------------------------------------------------------- 1 | name: stateless-component 2 | description: A component without its own database or state. This shows how to connect to dependencies to serve business logic. 3 | homepage: https://github.com/architect-team/architect-cli/tree/main/examples/stateless-component 4 | keywords: 5 | - architect 6 | - examples 7 | - stateless 8 | - dependencies 9 | - nextjs 10 | - reactjs 11 | 12 | secrets: 13 | log_level: 14 | default: debug 15 | description: | 16 | Applied as an environment variable to each service in the component 17 | (oneof: ['error', 'warning', 'debug', 'info', 'trace']) 18 | hello_world_tag: 19 | default: latest 20 | description: | 21 | The tag of the hello-world service to use as a dependency 22 | 23 | dependencies: 24 | hello-world: ${{ secrets.hello_world_tag }} 25 | 26 | services: 27 | stateless-app: 28 | build: 29 | context: ./ 30 | interfaces: 31 | http: 32 | port: 8080 33 | ingress: 34 | subdomain: frontend 35 | environment: 36 | LOG_LEVEL: ${{ secrets.log_level }} 37 | HELLO_WORLD_ADDR: ${{ dependencies.hello-world.services.api.interfaces.main.url }} 38 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/database-spec.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString } from 'class-validator'; 2 | import { JSONSchema } from 'class-validator-jsonschema'; 3 | import { SUPPORTED_DATABASE_TYPES } from './static/database-static'; 4 | import { ExpressionOr } from './utils/json-schema-annotations'; 5 | import { Slugs } from './utils/slugs'; 6 | 7 | @JSONSchema({ 8 | description: 'Component databases let you quickly spin up a database for your service', 9 | }) 10 | export class DatabaseSpec { 11 | @IsOptional() 12 | @JSONSchema({ 13 | type: 'string', 14 | description: 'Human readable description', 15 | }) 16 | description?: string; 17 | 18 | @IsString() 19 | @JSONSchema({ 20 | ...ExpressionOr({ type: 'string', enum: SUPPORTED_DATABASE_TYPES }), 21 | description: 'The type engine and version of database software needed for data storage.', 22 | errorMessage: Slugs.ComponentDatabaseDescription, 23 | }) 24 | type!: string; 25 | 26 | @IsOptional() 27 | @JSONSchema({ 28 | ...ExpressionOr({ format: 'uri', type: 'string' }, { type: 'null' }), 29 | description: 'The connection uri of an existing database to use instead of provisioning a new one', 30 | }) 31 | connection_string?: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/secret-spec.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional } from 'class-validator'; 2 | import { JSONSchema } from 'class-validator-jsonschema'; 3 | import { AnyOf, ExpressionOr } from './utils/json-schema-annotations'; 4 | 5 | export type SecretSpecValue = Array | boolean | number | string | null | undefined; 6 | 7 | @JSONSchema({ 8 | description: 'Components can define configurable secrets that can be used to enrich the contained services with environment-specific information (i.e. environment variables).', 9 | }) 10 | export class SecretDefinitionSpec { 11 | static readonly merge_key = 'default'; 12 | 13 | @IsOptional() 14 | @JSONSchema({ 15 | type: 'boolean', 16 | description: 'Denotes whether the secret is required.', 17 | }) 18 | required?: boolean; 19 | 20 | @IsOptional() 21 | @JSONSchema({ 22 | type: 'string', 23 | description: 'A human-friendly description of the secret.', 24 | }) 25 | description?: string; 26 | 27 | @IsOptional() 28 | @JSONSchema({ 29 | ...ExpressionOr(AnyOf('array', 'boolean', 'number', 'object', 'string', 'null')), 30 | description: 'Sets a default value for the secret if one is not provided', 31 | }) 32 | default?: SecretSpecValue; 33 | } 34 | -------------------------------------------------------------------------------- /src/app-config/credentials.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import AppConfig from './config'; 4 | 5 | const CREDENTIALS_FILENAME = 'creds.json'; 6 | 7 | export interface Credential { 8 | account: string; 9 | password: string; 10 | } 11 | 12 | export default class CredentialManager { 13 | private credentials_file: string; 14 | private credentials: { [key: string]: Credential }; 15 | 16 | constructor(config: AppConfig) { 17 | this.credentials_file = path.join(config.getConfigDir(), CREDENTIALS_FILENAME); 18 | fs.ensureFileSync(this.credentials_file); 19 | this.credentials = fs.readJSONSync(this.credentials_file, { throws: false }) || {}; 20 | } 21 | 22 | private save() { 23 | return fs.writeJSON(this.credentials_file, this.credentials, { replacer: null, spaces: 2 }); 24 | } 25 | 26 | async get(service: string): Promise { 27 | return this.credentials[service]; 28 | } 29 | 30 | async set(service: string, account: string, password: string): Promise { 31 | this.credentials[service] = { account, password }; 32 | await this.save(); 33 | } 34 | 35 | async delete(service: string): Promise { 36 | delete this.credentials[service]; 37 | await this.save(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/integration/stateful-component/frontend/src/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const next = require('next'); 3 | const axios = require('axios'); 4 | const bodyParser = require('body-parser'); 5 | 6 | const dev = process.env.NODE_ENV !== 'production'; 7 | const app = next({ dev }); 8 | const handle = app.getRequestHandler(); 9 | 10 | app.prepare().then(() => { 11 | const server = express(); 12 | 13 | server.all('/api/*', bodyParser.json(), async (req, res) => { 14 | try { 15 | const { status, data } = await axios({ 16 | url: `${process.env.API_ADDR}/${req.url.replace('/api/', '')}`, 17 | method: req.method, 18 | data: req.body, 19 | headers: req.headers, 20 | }); 21 | return res.status(status).json(data); 22 | } catch (err) { 23 | if (err.response) { 24 | return res.status(err.response.status).json(err.response.data); 25 | } else { 26 | return res.status(500).json({}); 27 | } 28 | } 29 | }); 30 | 31 | server.all('*', (req, res) => { 32 | return handle(req, res); 33 | }); 34 | 35 | const port = process.env.PORT || 8081; 36 | server.listen(port, (err) => { 37 | if (err) throw err; 38 | console.log(`> Ready on port: ${port}`); 39 | }) 40 | }) 41 | .catch((ex) => { 42 | console.error(ex.stack); 43 | process.exit(1); 44 | }); 45 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/task-spec.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, ValidateNested } from 'class-validator'; 2 | import { JSONSchema } from 'class-validator-jsonschema'; 3 | import { WithRequired } from '../../common/utils/types'; 4 | import { ResourceSpec } from './resource-spec'; 5 | import { ExclusiveOrNeither, ExpressionOrString } from './utils/json-schema-annotations'; 6 | import { ResourceType } from './utils/slugs'; 7 | 8 | @JSONSchema({ 9 | description: 'A Task represents a recurring and/or exiting runtime (e.g. crons, schedulers, triggered jobs). Each task will run on its specified schedule and/or be triggerable via the Architect CLI. Tasks are 1:1 with a docker image.', 10 | ...ExclusiveOrNeither('build', 'image'), 11 | }) 12 | export class TaskSpec extends ResourceSpec { 13 | get resource_type(): ResourceType { 14 | return 'tasks'; 15 | } 16 | 17 | @IsOptional() 18 | @ValidateNested() 19 | debug?: WithRequired, 'resource_type'>; 20 | 21 | @IsOptional() 22 | @JSONSchema({ 23 | ...ExpressionOrString({ 24 | format: 'cron', 25 | errorMessage: { 26 | format: 'must be a valid cron expression', 27 | }, 28 | }), 29 | description: 'A cron expression by which this task will be scheduled. Leave blank to deploy a task that never runs unless triggered from the CLI.', 30 | }) 31 | schedule?: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/dependency-manager/config/component-context.ts: -------------------------------------------------------------------------------- 1 | import { SecretSpecValue } from '../spec/secret-spec'; 2 | import { Dictionary } from '../utils/dictionary'; 3 | import { ServiceInterfaceConfig } from './service-config'; 4 | 5 | export type OutputValue = string | number | boolean | null; 6 | 7 | export interface DatabaseContext { 8 | protocol: string; 9 | host: string; 10 | port: number | string; 11 | username: string; 12 | password: string; 13 | database: string; 14 | connection_string: string; 15 | url: string; 16 | } 17 | 18 | export interface ServiceContext { 19 | environment?: Dictionary; 20 | interfaces: Dictionary; 21 | } 22 | 23 | export interface TaskContext { 24 | environment?: Dictionary; 25 | } 26 | 27 | export interface DependencyContext { 28 | outputs: Dictionary; 29 | services: Dictionary; 30 | } 31 | 32 | export interface ArchitectContext { 33 | environment: string; 34 | } 35 | 36 | export interface ComponentContext { 37 | name: string; 38 | dependencies: Dictionary; 39 | secrets: Dictionary; 40 | outputs: Dictionary; 41 | databases: Dictionary; 42 | services: Dictionary; 43 | tasks: Dictionary; 44 | 45 | architect: ArchitectContext; 46 | } 47 | -------------------------------------------------------------------------------- /test/commands/config/view.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import Table from 'cli-table3'; 3 | import fs from 'fs-extra'; 4 | import path from 'path'; 5 | import sinon from 'sinon'; 6 | import AppConfig from '../../../src/app-config/config'; 7 | import ConfigView from '../../../src/commands/config/view'; 8 | 9 | describe('config:view', () => { 10 | describe('expects results table', () => { 11 | const table = new Table({ 12 | head: ['Name', 'Value'], 13 | style: { 14 | head: ['green'] 15 | } 16 | }); 17 | 18 | const overrides = fs.readJSONSync(path.resolve('./test/config.json')); 19 | const config = new AppConfig(path.resolve('./test'), overrides).toJSON(); 20 | for (const entry of Object.entries(config)) { 21 | table.push(entry); 22 | } 23 | 24 | const log_spy = sinon.fake.returns(null); 25 | 26 | // set to true while working on tests for easier debugging; otherwise oclif/test eats the stdout/stderr 27 | const print = false; 28 | 29 | test 30 | .timeout(20000) 31 | .stub(ConfigView.prototype, 'log', log_spy) 32 | .stderr({ print }) 33 | .command(['config:view']) 34 | .it('calls app config and outputs expected log value', () => { 35 | expect(log_spy.calledOnce).to.equal(true); 36 | expect(log_spy.firstCall.args[0]).to.equal(table.toString()); 37 | }); 38 | }); 39 | }) 40 | -------------------------------------------------------------------------------- /test/dependency-manager/schema/component-transform.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test'; 2 | import { buildSpecFromYml, ComponentInstanceMetadata, loadSourceYmlFromPathOrReject, transformComponentSpec } from '../../../src'; 3 | 4 | describe('component transform unit test', function () { 5 | 6 | it(`transformComponentSpec successfully transforms spec without metadata`, async () => { 7 | const { source_yml } = loadSourceYmlFromPathOrReject(`test/mocks/superset/architect.yml`); 8 | 9 | const spec = buildSpecFromYml(source_yml); 10 | const config = transformComponentSpec(spec); 11 | 12 | expect(config.services['api-db'].metadata.ref).to.equal('superset.services.api-db'); 13 | }); 14 | 15 | it(`transformComponentSpec successfully transforms spec with metadata`, async () => { 16 | const { source_yml } = loadSourceYmlFromPathOrReject(`test/mocks/superset/architect.yml`); 17 | const ref = 'superset@instance-1'; 18 | 19 | const metadata: ComponentInstanceMetadata = { 20 | ref, 21 | tag: 'latest', 22 | instance_name: 'instance-1', 23 | instance_id: 'test-instance-id', 24 | instance_date: new Date(), 25 | deprecated_interfaces_map: {} 26 | }; 27 | const spec = buildSpecFromYml(source_yml, metadata); 28 | const config = transformComponentSpec(spec); 29 | 30 | expect(config.services['api-db'].metadata.ref).to.equal('superset.services.api-db@instance-1'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/commands/clusters/destroy.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { MockArchitectApi } from '../../utils/mocks'; 3 | 4 | describe('clusters:destroy', () => { 5 | const mock_account = { 6 | id: 'test-account-id', 7 | name: 'test-account' 8 | } 9 | 10 | const mock_cluster = { 11 | id: 'test-cluster-id', 12 | name: 'test-cluster' 13 | } 14 | 15 | const mock_pipeline = { 16 | id: 'test-pipeline-id' 17 | } 18 | 19 | new MockArchitectApi({ timeout: 20000 }) 20 | .getAccount(mock_account) 21 | .getCluster(mock_account, mock_cluster) 22 | .getCluster(mock_account, mock_cluster) 23 | .deleteCluster(mock_cluster, mock_pipeline) 24 | .pollPipeline(mock_pipeline) 25 | .getTests() 26 | .command(['clusters:destroy', '-a', mock_account.name, mock_cluster.name, '--auto-approve']) 27 | .it('should generate destroy deployment', ctx => { 28 | expect(ctx.stdout).to.contain('Cluster deregistered\n') 29 | }); 30 | 31 | new MockArchitectApi({ timeout: 20000 }) 32 | .getAccount(mock_account) 33 | .getCluster(mock_account, mock_cluster) 34 | .getCluster(mock_account, mock_cluster) 35 | .deleteCluster(mock_cluster, mock_pipeline, { force: 1 }) 36 | .pollPipeline(mock_pipeline) 37 | .getTests() 38 | .command(['clusters:destroy', '-a', mock_account.name, mock_cluster.name, '--auto-approve', '--force']) 39 | .it('should force apply destroy job', ctx => { 40 | expect(ctx.stdout).to.contain('Cluster deregistered\n') 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/commands/platforms/destroy.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { MockArchitectApi } from '../../utils/mocks'; 3 | 4 | describe('environment:destroy', () => { 5 | const mock_account = { 6 | id: 'test-account-id', 7 | name: 'test-account' 8 | } 9 | 10 | const mock_cluster = { 11 | id: 'test-cluster-id', 12 | name: 'test-cluster' 13 | } 14 | 15 | const mock_pipeline = { 16 | id: 'test-pipeline-id' 17 | } 18 | 19 | new MockArchitectApi({ timeout: 20000 }) 20 | .getAccount(mock_account) 21 | .getCluster(mock_account, mock_cluster) 22 | .getCluster(mock_account, mock_cluster) 23 | .deleteCluster(mock_cluster, mock_pipeline) 24 | .pollPipeline(mock_pipeline) 25 | .getTests() 26 | .command(['platforms:destroy', '-a', mock_account.name, mock_cluster.name, '--auto-approve']) 27 | .it('should generate destroy deployment', ctx => { 28 | expect(ctx.stdout).to.contain('Cluster deregistered\n') 29 | }); 30 | 31 | new MockArchitectApi({ timeout: 20000 }) 32 | .getAccount(mock_account) 33 | .getCluster(mock_account, mock_cluster) 34 | .getCluster(mock_account, mock_cluster) 35 | .deleteCluster(mock_cluster, mock_pipeline, { force: 1 }) 36 | .pollPipeline(mock_pipeline) 37 | .getTests() 38 | .command(['platforms:destroy', '-a', mock_account.name, mock_cluster.name, '--auto-approve', '--force']) 39 | .it('should force apply destroy job', ctx => { 40 | expect(ctx.stdout).to.contain('Cluster deregistered\n') 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/dependency-manager/graph/node/service.ts: -------------------------------------------------------------------------------- 1 | import { DependencyNode, DependencyNodeOptions } from '.'; 2 | import { ServiceConfig, ServiceInterfaceConfig } from '../../config/service-config'; 3 | 4 | export interface ServiceNodeOptions { 5 | ref: string; 6 | component_ref: string; 7 | service_name: string; 8 | config: ServiceConfig; 9 | local_path?: string; 10 | artifact_image?: string; 11 | } 12 | 13 | export class ServiceNode extends DependencyNode implements ServiceNodeOptions { 14 | __type = 'service'; 15 | 16 | config!: ServiceConfig; 17 | 18 | ref!: string; 19 | component_ref!: string; 20 | service_name!: string; 21 | local_path?: string; 22 | artifact_image?: string; 23 | 24 | constructor(options: ServiceNodeOptions & DependencyNodeOptions) { 25 | super(); 26 | if (options) { 27 | this.ref = options.ref; 28 | this.component_ref = options.component_ref; 29 | this.service_name = options.service_name; 30 | this.config = options.config; 31 | this.artifact_image = options.artifact_image; 32 | this.local_path = options.local_path; 33 | } 34 | } 35 | 36 | get interfaces(): { [key: string]: ServiceInterfaceConfig } { 37 | return this.config.interfaces; 38 | } 39 | 40 | get ports(): string[] { 41 | const ports = Object.values(this.interfaces).map((i) => i.port) as string[]; 42 | return [...new Set(ports)]; 43 | } 44 | 45 | get is_external(): boolean { 46 | return Object.keys(this.interfaces).length > 0 && Object.values(this.interfaces).every((i) => i.host); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/link/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import path from 'path'; 3 | import untildify from 'untildify'; 4 | import { buildSpecFromPath } from '../..'; 5 | import BaseCommand from '../../base-command'; 6 | 7 | declare const process: NodeJS.Process; 8 | 9 | export default class Link extends BaseCommand { 10 | async auth_required(): Promise { 11 | return false; 12 | } 13 | 14 | static description = 'Link a local component to the host to be used to power local deployments.'; 15 | static examples = [ 16 | 'architect link', 17 | 'architect link -p ./mycomponent/architect.yml', 18 | ]; 19 | static flags = { 20 | ...BaseCommand.flags, 21 | }; 22 | 23 | static args = [{ 24 | sensitive: false, 25 | name: 'componentPath', 26 | char: 'p', 27 | description: 'The path of the component to link', 28 | default: '.', 29 | }]; 30 | 31 | async run(): Promise { 32 | const { args } = await this.parse(Link); 33 | 34 | const component_path = path.resolve(untildify(args.componentPath)); 35 | // Try to load the component from the path to ensure it exists and is valid 36 | try { 37 | const component_config = buildSpecFromPath(component_path); 38 | this.app.linkComponentPath(component_config.name, component_path); 39 | this.log(`Successfully linked ${chalk.green(component_config.name)} to local system at ${chalk.green(component_path)}.`); 40 | } catch (err: any) { 41 | if (err.name === 'missing_config_file') { 42 | this.log(chalk.red(err.message)); 43 | } else { 44 | throw err; 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/transform/database-transform.ts: -------------------------------------------------------------------------------- 1 | import { coerce } from 'semver'; 2 | import { DatabaseConfig, ServiceConfig } from '../../config/service-config'; 3 | import { ArchitectError } from '../../utils/errors'; 4 | import { ComponentInstanceMetadata } from '../component-spec'; 5 | import { DatabaseSpec } from '../database-spec'; 6 | import { SupportedDatabases } from '../static/database-static'; 7 | import { Slugs } from '../utils/slugs'; 8 | import { transformServiceSpec } from './service-transform'; 9 | 10 | export const transformDatabaseSpecToServiceSpec = (key: string, db_spec: DatabaseSpec, metadata: ComponentInstanceMetadata): ServiceConfig => { 11 | const [engine, version] = db_spec.type.split(':'); 12 | 13 | const semver_version = coerce(version); 14 | if (!semver_version) { 15 | throw new ArchitectError(`Unable to parse out database version for requested image: ${key}`); 16 | } 17 | 18 | const match = SupportedDatabases.find(match => 19 | match.engine === engine && 20 | semver_version?.major >= (match.versions.min || 0) && 21 | semver_version?.major <= (match.versions.max || 1000)); 22 | 23 | if (!match) { 24 | throw new Error(`Unsupported database engine: ${engine}`); 25 | } 26 | 27 | const service_spec = match.spec; 28 | service_spec.image = db_spec.type; 29 | return transformServiceSpec(`${key}${Slugs.DB_SUFFIX}`, service_spec, metadata); 30 | }; 31 | 32 | export const transformDatabaseSpec = (key: string, db_spec: DatabaseSpec, metadata: ComponentInstanceMetadata): DatabaseConfig => { 33 | return { 34 | ...db_spec, 35 | url: db_spec.connection_string, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /test/integration/stateful-component/architect.yml: -------------------------------------------------------------------------------- 1 | name: stateful-component 2 | description: A simple sign in sheet webapp built with a Next.js frontend, Express JS backend, and postgres database. 3 | homepage: https://github.com/architect-team/architect-cli/tree/main/examples/stateful-component 4 | keywords: 5 | - architect 6 | - examples 7 | - stateful 8 | - postgres 9 | - nodejs 10 | - reactjs 11 | 12 | secrets: 13 | db_user: 14 | description: Root user to assign to the component's DB 15 | default: architect 16 | db_pass: 17 | description: Root password to assign to the component's DB 18 | default: secret 19 | db_name: 20 | description: Name of the DB the component will store content in 21 | default: stateful 22 | 23 | services: 24 | api-db: 25 | image: postgres:12 26 | interfaces: 27 | postgres: 28 | port: 5432 29 | protocol: postgresql 30 | environment: 31 | POSTGRES_USER: ${{ secrets.db_user }} 32 | POSTGRES_PASSWORD: ${{ secrets.db_pass }} 33 | POSTGRES_DB: ${{ secrets.db_name }} 34 | stateful-api: 35 | build: 36 | context: ./backend 37 | interfaces: 38 | http: 8080 39 | environment: 40 | DB_ADDR: ${{ services.api-db.interfaces.postgres.url }}/${{ secrets.db_name }} 41 | DB_USER: ${{ secrets.db_user }} 42 | DB_PASS: ${{ secrets.db_pass }} 43 | frontend: 44 | build: 45 | context: ./frontend 46 | interfaces: 47 | web: 48 | port: 8081 49 | ingress: 50 | subdomain: frontend 51 | environment: 52 | API_ADDR: ${{ services.stateful-api.interfaces.http.url }} 53 | -------------------------------------------------------------------------------- /.github/workflows/register-examples.yml: -------------------------------------------------------------------------------- 1 | name: Register Example Components 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'examples/**/*' 9 | 10 | jobs: 11 | detect-changes: 12 | runs-on: ubuntu-latest 13 | outputs: 14 | matrix: ${{ steps.detect.outputs.matrix }} 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 0 20 | - name: Install jq 21 | run: sudo apt-get update && sudo apt-get install jq 22 | - id: detect 23 | name: Detect changes 24 | run: | 25 | export CHANGED_DIRS=$(find ./examples/*/architect.yml -type f -maxdepth 0 -exec sh -c 'test $(git diff-tree --name-only --no-commit-id -r ${{ github.sha }} -- $(dirname $1) | wc -c) -ne 0 && dirname $1' sh {} \;) 26 | echo "::set-output name=matrix::$(jq -n -c --arg v "$CHANGED_DIRS" '{"component_path": $v | split("\n") }')" 27 | 28 | register-components: 29 | needs: detect-changes 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: ${{fromJson(needs.detect-changes.outputs.matrix)}} 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions/setup-node@v2 36 | with: 37 | node-version: '16' 38 | - name: Install Architect CLI 39 | run: sudo npm install -g @architect-io/cli 40 | - name: Architect login 41 | run: architect login -e ${{ secrets.ARCHITECT_EXAMPLES_ACCT_EMAIL }} -p ${{ secrets.ARCHITECT_EXAMPLES_ACCT_PASSWORD }} 42 | - name: Register component 43 | run: architect register ${{ matrix.component_path }} -a examples -t latest 44 | -------------------------------------------------------------------------------- /src/common/kubectl/helper.ts: -------------------------------------------------------------------------------- 1 | import which from 'which'; 2 | import { ArchitectError } from '../../dependency-manager/utils/errors'; 3 | 4 | class _KubectlHelper { 5 | kubectl_installed: boolean; 6 | 7 | constructor() { 8 | this.kubectl_installed = this.checkKubectlInstalled(); 9 | } 10 | 11 | static getTestHelper(): _KubectlHelper { 12 | const helper = new _KubectlHelper(); 13 | helper.kubectl_installed = true; 14 | return helper; 15 | } 16 | 17 | checkKubectlInstalled(): boolean { 18 | try { 19 | which.sync('kubectl'); 20 | return true; 21 | } catch { 22 | return false; 23 | } 24 | } 25 | 26 | verifyKubectl(): void { 27 | if (!this.kubectl_installed) { 28 | throw new ArchitectError('Architect requires Kubectl to be installed.\nPlease install kubectl and try again: https://kubernetes.io/docs/tasks/tools/#kubectl'); 29 | } 30 | } 31 | } 32 | 33 | export const KubernetesHelper = process.env.TEST === '1' ? _KubectlHelper.getTestHelper() : new _KubectlHelper(); 34 | 35 | /** 36 | * Used to wrap functions that require kubectl. Makes sure that kubectl is installed 37 | * before work begins. 38 | */ 39 | export function RequiresKubectl(): (target: any, propertyKey: string, descriptor: PropertyDescriptor) => any { 40 | return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { 41 | const wrappedFunc = descriptor.value; 42 | descriptor.value = function (this: any, ...args: any[]) { 43 | // We always want to verify kubectl is installed 44 | KubernetesHelper.verifyKubectl(); 45 | 46 | return wrappedFunc.apply(this, args); 47 | }; 48 | return descriptor; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/common/utils/git/helper.ts: -------------------------------------------------------------------------------- 1 | import which from 'which'; 2 | 3 | class _GitHelper { 4 | git_installed: boolean; 5 | 6 | constructor() { 7 | this.git_installed = this.checkGitInstalled(); 8 | } 9 | 10 | static getTestHelper(): _GitHelper { 11 | const helper = new _GitHelper(); 12 | helper.git_installed = true; 13 | return helper; 14 | } 15 | 16 | checkGitInstalled(): boolean { 17 | try { 18 | which.sync('git'); 19 | return true; 20 | } catch { 21 | return false; 22 | } 23 | } 24 | 25 | verifyGit(): void { 26 | if (!this.git_installed) { 27 | throw new Error('Architect requires git to be installed in order for this command to run.\nPlease install git and try again: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git'); 28 | } 29 | } 30 | } 31 | 32 | // Create a singleton GitHelper 33 | export const GitHelper = process.env.TEST === '1' ? _GitHelper.getTestHelper() : new _GitHelper(); 34 | 35 | /** 36 | * Used to wrap `Command.run()` or `Command.runLocal()` methods when git is required. 37 | * Should be used as close to the run method as possible so the checks for required git features happen 38 | * before any work begins. 39 | */ 40 | export function RequiresGit(): (target: any, propertyKey: string, descriptor: PropertyDescriptor) => any { 41 | return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { 42 | const wrappedFunc = descriptor.value; 43 | descriptor.value = function (this: any, ...args: any[]) { 44 | // Verify that git is installed 45 | GitHelper.verifyGit(); 46 | 47 | return wrappedFunc.apply(this, args); 48 | }; 49 | return descriptor; 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /test/commands/destroy.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import PipelineUtils from '../../src/architect/pipeline/pipeline.utils'; 3 | import { MockArchitectApi } from '../utils/mocks'; 4 | 5 | describe('destroy', function () { 6 | const mock_account = { 7 | id: 'test-account-id', 8 | name: 'test-account', 9 | }; 10 | 11 | const mock_env = { 12 | id: 'test-env-id', 13 | name: 'test-env', 14 | }; 15 | 16 | const mock_pipeline = { 17 | id: 'test-pipeline-id', 18 | environment: mock_env 19 | }; 20 | 21 | new MockArchitectApi({ timeout: 20000 }) 22 | .getAccount(mock_account) 23 | .getEnvironment(mock_account, mock_env) 24 | .deleteEnvironmentInstances(mock_env, mock_pipeline) 25 | .approvePipeline(mock_pipeline) 26 | .pollPipeline(mock_pipeline) 27 | .getTests() 28 | .stub(PipelineUtils, 'pollPipeline', async () => mock_pipeline) 29 | .command(['destroy', '-a', mock_account.name, '-e', mock_env.name, '--auto-approve']) 30 | .it('destroy completes', ctx => { 31 | expect(ctx.stdout).to.contain('Deployed\n'); 32 | }); 33 | 34 | new MockArchitectApi() 35 | .getAccount(mock_account) 36 | .getEnvironment(mock_account, mock_env) 37 | .deleteEnvironmentInstances(mock_env, mock_pipeline) 38 | .approvePipeline(mock_pipeline) 39 | .pollPipeline(mock_pipeline) 40 | .getTests() 41 | .command(['destroy', '-a', mock_account.name, '-e', mock_env.name, '--auto_approve']) 42 | .it('destroy completes with a warning when using a deprecated flag', ctx => { 43 | expect(ctx.stderr).to.contain('Warning: The "auto_approve" flag has been deprecated.'); 44 | expect(ctx.stdout).to.contain('Deployed\n'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/test-setup.ts: -------------------------------------------------------------------------------- 1 | process.env.CI = 'true' 2 | import mock_fs from 'mock-fs'; 3 | import nock from 'nock'; 4 | import 'reflect-metadata'; 5 | import sinon from 'sinon'; 6 | import CredentialManager from '../src/app-config/credentials'; 7 | import { DockerUtils } from '../src/common/docker'; 8 | import PluginManager from '../src/common/plugins/plugin-manager'; 9 | import PortUtil from '../src/common/utils/port'; 10 | import PromptUtils from '../src/common/utils/prompt-utils'; 11 | 12 | PromptUtils.disablePrompts(); 13 | 14 | for (const env_key of Object.keys(process.env)) { 15 | if (env_key.startsWith('ARC_')) { 16 | delete process.env[env_key]; 17 | } 18 | } 19 | process.env.ARCHITECT_CONFIG_DIR = './test' 20 | process.env.NODE_ENV = 'development' 21 | process.env.TEST = '1' 22 | 23 | // @ts-ignore 24 | global.oclif = global.oclif || {} 25 | // @ts-ignore 26 | global.oclif.columns = 120 27 | 28 | exports.mochaHooks = { 29 | beforeEach(done: any) { 30 | nock.disableNetConnect(); 31 | nock('localhost').get('/v1/auth/approle/login').reply(200, { auth: {} }); 32 | 33 | sinon.replace(DockerUtils, 'doesDockerfileExist', () => true); 34 | 35 | sinon.replace(PluginManager, 'getPlugin', async () => ({ 36 | build: () => { }, 37 | } as any)) 38 | 39 | sinon.replace(CredentialManager.prototype, 'get', async (service: string) => { 40 | return { 41 | account: 'test', 42 | password: '{}' 43 | } 44 | }); 45 | 46 | sinon.replace(PortUtil, 'isPortAvailable', async () => true); 47 | PortUtil.reset(); 48 | done(); 49 | }, 50 | 51 | afterEach(done: any) { 52 | sinon.restore(); 53 | mock_fs.restore(); 54 | nock.cleanAll(); 55 | nock.enableNetConnect(); 56 | done(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app-config/posthog.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import { PostHog, PostHogOptions } from 'posthog-node'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | type PostHogCliOptions = Omit & ({ 6 | persistence: 'memory' 7 | } | { 8 | persistence: 'file'; 9 | propertiesFile: string; 10 | } | Record); 11 | 12 | export class PostHogCli extends PostHog { 13 | private _properties: Record; 14 | 15 | constructor(apiKey: string, private _options: PostHogCliOptions) { 16 | super(apiKey, { 17 | ..._options, 18 | persistence: 'memory', 19 | }); 20 | 21 | this._properties = {}; 22 | if (this._options.persistence === 'file' && fs.existsSync(this._options.propertiesFile)) { 23 | this._properties = fs.readJSONSync(this._options.propertiesFile); 24 | } 25 | 26 | if (!this._properties.anonymous_id) { 27 | this.setPersistedProperty('anonymous_id', uuidv4()); 28 | } 29 | } 30 | 31 | /** 32 | * @override 33 | */ 34 | getPersistedProperty(key: string): any | undefined { 35 | if (this._options.persistence === 'file') { 36 | return this._properties[key]; 37 | } else { 38 | return super.getPersistedProperty(key as any); 39 | } 40 | } 41 | 42 | /** 43 | * @override 44 | */ 45 | setPersistedProperty(key: string, value: any | null): void { 46 | super.setPersistedProperty(key as any, value); 47 | this._properties[key] = value; 48 | 49 | if (this._options.persistence === 'file') { 50 | fs.ensureFileSync(this._options.propertiesFile); 51 | fs.writeJSONSync(this._options.propertiesFile, this._properties); 52 | } 53 | } 54 | 55 | capture(message: { event: string; properties?: Record; }): void { 56 | return super.capture({ distinctId: this.getPersistedProperty('anonymous_id'), ...message }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/mocks/examples/database-seeding.architect.yml: -------------------------------------------------------------------------------- 1 | name: database-seeding 2 | description: Example express application using typeorm to seed test data 3 | homepage: https://github.com/architect-team/architect-cli/tree/main/examples/database-seeding 4 | keywords: 5 | - architect 6 | - examples 7 | - postgres 8 | - nodejs 9 | - express-js 10 | 11 | secrets: 12 | auto_ddl: 13 | description: Options are 'none', 'migrate', and 'seed'; none- no ddl; migrate- runs unrun database migrations at application start; seed- runs unrun migrations and test data seeding script at application start 14 | default: migrate 15 | db_user: 16 | description: Username used to access the database 17 | default: postgres 18 | db_pass: 19 | description: Password used to access the database 20 | default: architect 21 | db_name: 22 | description: Name of the database instance containing the relevant API tables 23 | default: seeding_demo 24 | 25 | services: 26 | app: 27 | build: 28 | context: ../../integration/hello-world/ 29 | dockerfile: ./Dockerfile 30 | interfaces: 31 | main: 32 | port: 3000 33 | ingress: 34 | subdomain: app 35 | environment: 36 | DATABASE_HOST: ${{ services.my-demo-db.interfaces.postgres.host }} 37 | DATABASE_PORT: ${{ services.my-demo-db.interfaces.postgres.port }} 38 | DATABASE_USER: ${{ services.my-demo-db.environment.POSTGRES_USER }} 39 | DATABASE_PASSWORD: ${{ services.my-demo-db.environment.POSTGRES_PASSWORD }} 40 | DATABASE_SCHEMA: ${{ services.my-demo-db.environment.POSTGRES_DB }} 41 | AUTO_DDL: ${{ secrets.auto_ddl }} 42 | 43 | my-demo-db: 44 | image: postgres:11 45 | interfaces: 46 | postgres: 5432 47 | environment: 48 | POSTGRES_DB: ${{ secrets.db_name }} 49 | POSTGRES_USER: ${{ secrets.db_user }} 50 | POSTGRES_PASSWORD: ${{ secrets.db_pass }} 51 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/transform/service-transform.ts: -------------------------------------------------------------------------------- 1 | import { ServiceConfig, ServiceInterfaceConfig } from '../../config/service-config'; 2 | import { transformDictionary } from '../../utils/dictionary'; 3 | import { ComponentInstanceMetadata } from '../component-spec'; 4 | import { ServiceInterfaceSpec, ServiceSpec } from '../service-spec'; 5 | import { transformLivenessProbeSpec, transformVolumeSpec } from './common-transform'; 6 | import { transformResourceSpec } from './resource-transform'; 7 | 8 | export const transformInterfaceSpec = function (key: string, interface_spec: ServiceInterfaceSpec | string | number): ServiceInterfaceConfig { 9 | if (interface_spec instanceof Object) { 10 | const interface_config: ServiceInterfaceConfig = interface_spec as Omit; 11 | 12 | if (interface_spec.ingress) { 13 | interface_config.ingress = { 14 | ...interface_spec.ingress, 15 | private: Boolean(interface_spec.ingress.private) || false, 16 | }; 17 | } 18 | 19 | return interface_config; 20 | } else { 21 | return { port: interface_spec }; 22 | } 23 | }; 24 | 25 | export const transformServiceSpec = (key: string, spec: ServiceSpec, metadata: ComponentInstanceMetadata): ServiceConfig => { 26 | const resource_config = transformResourceSpec('services', key, spec, metadata); 27 | 28 | return { 29 | ...resource_config, 30 | enabled: spec.enabled || true, 31 | debug: spec.debug ? transformServiceSpec(key, spec.debug, metadata) : undefined, 32 | interfaces: transformDictionary(transformInterfaceSpec, spec.interfaces), 33 | liveness_probe: transformLivenessProbeSpec(spec.liveness_probe), 34 | volumes: transformDictionary(transformVolumeSpec, spec.volumes), 35 | replicas: spec.replicas || 1, 36 | scaling: spec.scaling, 37 | deploy: spec.deploy, 38 | termination_grace_period: spec.termination_grace_period || '30s', 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/dependency-manager/README.md: -------------------------------------------------------------------------------- 1 | Architect dependency-manager 2 | ============= 3 | 4 | Library used for ingesting component specs and converting them to application graphs. 5 | 6 | ## Generating and using json schema 7 | 8 | This project uses the [JSON schema](https://json-schema.org/) spec to help validate `architect.yml` files describing components. In order to make this schema easier to manage, we use the [typescript-json-schema]() library to generate the schema definition from a typescript interface. 9 | 10 | After you've made changes to the typescript files representing the schema(s), run the associated generate:schema command: 11 | 12 | ```sh 13 | $ npm run generate:schema:v1 14 | ``` 15 | 16 | This will write the schema file to `./v1-component-schema.json`. 17 | 18 | ### Testing the schema in VS Code 19 | 20 | The first thing you'll need to do is install [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) for VS Code. This is needed to allow your editor to respond to json schema templates found locally or in the json schema store, a public repository of json schemas associated with filenames. 21 | 22 | Once you have the extension installed, ppen your [VS Code settings.json file](https://code.visualstudio.com/docs/getstarted/settings#_settings-file-locations) and associate the generated schema file with the `architect.yml` and `architect.yaml` file types: 23 | 24 | ```json 25 | { 26 | "yaml.schemas": { 27 | "/src/dependency-manager/v1-component-schema.json": ["architect.yaml", "architect.yml"] 28 | } 29 | } 30 | ``` 31 | 32 | _Be sure to replace `` with the directory where you checked out the Architect CLI project._ 33 | 34 | ### Publishing schema to public store 35 | 36 | Unfortunately this can't be automated. You'll have to submit a PR to the repository and follow the contributing guidelines: 37 | 38 | https://github.com/SchemaStore/schemastore/blob/master/CONTRIBUTING.md 39 | -------------------------------------------------------------------------------- /test/commands/link/list.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import fs from 'fs-extra'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import sinon, { SinonSpy } from 'sinon'; 6 | import AppConfig from '../../../src/app-config/config'; 7 | import AppService from '../../../src/app-config/service'; 8 | import BaseTable from '../../../src/base-table'; 9 | import ListLinkedComponents from '../../../src/commands/link/list'; 10 | import ARCHITECTPATHS from '../../../src/paths'; 11 | 12 | describe('link:list', () => { 13 | let tmp_dir = os.tmpdir(); 14 | 15 | beforeEach(() => { 16 | // Stub the log_level 17 | const config = new AppConfig('', { 18 | log_level: 'info', 19 | }); 20 | 21 | const tmp_linked_components_file = path.join(tmp_dir, ARCHITECTPATHS.LINKED_COMPONENT_MAP_FILENAME); 22 | fs.writeJSONSync(tmp_linked_components_file, { 'superset': '../../mocks/superset/' }); 23 | const tmp_config_file = path.join(tmp_dir, ARCHITECTPATHS.CLI_CONFIG_FILENAME); 24 | fs.writeJSONSync(tmp_config_file, config); 25 | const app_config_stub = sinon.stub().returns(new AppService(tmp_dir, '0.0.1')); 26 | sinon.replace(AppService, 'create', app_config_stub); 27 | }); 28 | 29 | afterEach(() => { 30 | sinon.restore(); 31 | }); 32 | 33 | describe('list linked components', async () => { 34 | const linked_components = { 'superset': '../../mocks/superset/' }; 35 | const table = new BaseTable({ head: ['Component', 'Path'] }); 36 | for (const entry of Object.entries(linked_components)) { 37 | table.push(entry); 38 | } 39 | 40 | test 41 | .stub(ListLinkedComponents.prototype, 'log', sinon.fake.returns(null)) 42 | .command(['link:list']) 43 | .it('list all linked components', () => { 44 | const log_spy_list = ListLinkedComponents.prototype.log as SinonSpy; 45 | expect(log_spy_list.firstCall.args[0]).to.equal(table.toString()); 46 | }); 47 | }); 48 | }) 49 | -------------------------------------------------------------------------------- /test/integration/hello-world/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | Architect Logo 6 | 7 |

8 | 9 |

10 | A dynamic microservices framework for building, connecting, and deploying cloud-native applications. 11 |

12 | 13 | --- 14 | 15 | # Hello world w/ Architect 16 | 17 | This example will show you the leanest possible use-case for Architect – "Hello world"! In this example, we've written a component spec (the `architect.yml` file) that defines a component powered our own Docker image. From there it goes on to annotate the ports the service listens on and the interfaces that should be exposed to upstream callers. 18 | 19 | [Learn more about the architect.yml file](//docs.architect.io/configuration) 20 | 21 | ## Running locally 22 | 23 | Architect component specs are declarative, so it can be run locally or remotely with a single deploy command: 24 | 25 | ```sh 26 | # Clone the repository and navigate to this directory 27 | $ git clone git@github.com:architect-community/hello-world.git 28 | $ cd ./hello-world 29 | 30 | # Deploy using the dev command 31 | $ architect dev ./architect.yml 32 | ``` 33 | 34 | Once the deploy has completed, you can reach your new service by going to https://hello.localhost.architect.sh/. 35 | 36 | ## Deploying to the cloud 37 | 38 | Want to try deploying this to a cloud environment? Architect's got you covered there too! if you've already [created your account](https://cloud.architect.io/signup), you can run the command below to deploy the component to a sample Kubernetes cluster powered by Architect Cloud: 39 | 40 | ```sh 41 | $ architect deploy ./architect.yml -e example-environment 42 | ``` 43 | -------------------------------------------------------------------------------- /test/integration/stateful-component/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | Architect Logo 6 | 7 |

8 | 9 |

10 | A dynamic microservices framework for building, connecting, and deploying cloud-native applications. 11 |

12 | 13 | --- 14 | 15 | # Stateful Architect components 16 | 17 | Almost all microservices or backend stacks require a database or some for of state in order to persist important user or session data. In this example, you'll see how you can describe an `architect.yml` file that includes a frontend webapp, a backend API, and of course a database allocated privately for said API. 18 | 19 | [Learn more about the architect.yml file](//docs.architect.io/configuration) 20 | 21 | ## Running locally 22 | 23 | Architect component specs are declarative, so it can be run locally or remotely with a single deploy command: 24 | 25 | ```sh 26 | # Clone the repository and navigate to this directory 27 | $ git clone https://github.com/architect-team/architect-cli.git 28 | $ cd ./architect-cli/examples/stateful-component 29 | 30 | # Deploy using the dev command 31 | $ architect dev ./architect.yml 32 | ``` 33 | 34 | Once the deploy has completed, you can reach your new service by going to https://frontend.localhost.architect.sh/. 35 | 36 | ## Deploying to the cloud 37 | 38 | Want to try deploying this to a cloud environment? Architect's got you covered there too! if you've already [created your account](https://cloud.architect.io/signup), you can run the command below to deploy the component to a sample Kubernetes cluster powered by Architect Cloud: 39 | 40 | ```sh 41 | $ architect deploy ./architect.yml -e example-environment 42 | ``` 43 | -------------------------------------------------------------------------------- /src/commands/components/versions.ts: -------------------------------------------------------------------------------- 1 | import Account from '../../architect/account/account.entity'; 2 | import AccountUtils from '../../architect/account/account.utils'; 3 | import { ComponentVersion } from '../../architect/component/component-version.entity'; 4 | import BaseCommand from '../../base-command'; 5 | import Table from '../../base-table'; 6 | import localizedTimestamp from '../../common/utils/localized-timestamp'; 7 | 8 | export default class ComponentVersions extends BaseCommand { 9 | static aliases = ['component:versions', 'component:version']; 10 | static description = 'Search component versions of a particular component'; 11 | static examples = [ 12 | 'architect component:versions mycomponent', 13 | 'architect component:versions --account=myaccount mycomponent', 14 | ]; 15 | static flags = { 16 | ...BaseCommand.flags, 17 | ...AccountUtils.flags, 18 | }; 19 | 20 | static args = [{ 21 | name: 'component_name', 22 | sensitive: false, 23 | }]; 24 | 25 | async run(): Promise { 26 | const { args, flags } = await this.parse(ComponentVersions); 27 | 28 | if (!args.component_name) { 29 | this.log('You must specify the name of a component.'); 30 | return; 31 | } 32 | 33 | const account: Account = await AccountUtils.getAccount(this.app, flags.account); 34 | 35 | const { data: component } = await this.app.api.get(`/accounts/${account.name}/components/${args.component_name}`); 36 | const { data: { rows: component_versions } } = await this.app.api.get(`/components/${component.component_id}/versions`); 37 | 38 | const table = new Table({ head: ['Tag', 'Created'] }); 39 | for (const component_version of component_versions.sort((cv1: ComponentVersion, cv2: ComponentVersion) => cv1.tag.localeCompare(cv2.tag))) { 40 | table.push([ 41 | component_version.tag, 42 | localizedTimestamp(component_version.created_at), 43 | ]); 44 | } 45 | 46 | this.log(table.toString()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/integration/scheduled-tasks/Readme.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | Architect Logo 6 | 7 |

8 | 9 |

10 | A dynamic microservices framework for building, connecting, and deploying cloud-native applications. 11 |

12 | 13 | --- 14 | 15 | # Task scheduling / cron jobs 16 | 17 | In addition to provisioning and updating persistent services, [Architect Components](//docs.architect.io/configuration) can also describe and create tasks that will run on a specified schedule (aka cron jobs). 18 | 19 | Just like `services`, `tasks` can take advantage of Architect's embedded service discovery and network security features to automatically connect to peer services without additional configuration. This means that no additional configuration is needed when deploying the component to ensure it can perform its duties. 20 | 21 | In this example component (described by the `architect.yml` file in this repo), we've registered a simple hello-world service and a `curler` task that will routinely make a call to the hello-world service. This task runs on a schedule indicated by the `schedule` field. 22 | 23 | ### Testing the component 24 | 25 | When you run the component locally the task will be configured but won't run on its schedule. This is because the environment is short-lived by nature rendering schedules of little use. Instead, testing tasks can be done manually to ensure that they work correctly. Just run the component and then you'll be able to manually execute the task: 26 | 27 | ```sh 28 | # Deploy the component locally 29 | $ architect dev architect.yml 30 | # In another terminal session, execute the task 31 | $ architect task:exec --local scheduled-tasks curler 32 | ``` 33 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | const { GIT_BRANCH: branch } = process.env; 2 | 3 | const commitAnalyzer = '@semantic-release/commit-analyzer'; 4 | const releaseNotesGenerator = '@semantic-release/release-notes-generator'; 5 | const git = [ 6 | '@semantic-release/git', 7 | { 8 | 'assets': [ 9 | 'CHANGELOG.md', 10 | 'README.md', 11 | 'package.json', 12 | 'package-lock.json', 13 | 'yarn.lock', 14 | 'architect-yml.md', 15 | 'src/dependency-manager/schema/architect.schema.json', 16 | ], 17 | }, 18 | ]; 19 | const exec = [ 20 | '@semantic-release/exec', 21 | { 22 | 'publishCmd': 'npm run pack', 23 | }, 24 | ]; 25 | const npm = '@semantic-release/npm'; 26 | const github = [ 27 | '@semantic-release/github', 28 | { 29 | 'assets': [ 30 | { 31 | 'path': 'dist/*.tar.gz', 32 | 'label': 'Architect-CLI ${nextRelease.version}', 33 | }, 34 | ], 35 | }, 36 | ]; 37 | const changelog = [ 38 | '@semantic-release/changelog', 39 | { 40 | 'changelogFile': 'CHANGELOG.md', 41 | }, 42 | ]; 43 | const backmerge = [ 44 | '@saithodev/semantic-release-backmerge', 45 | { 46 | 'branches': ['rc'], 47 | // Makes sure that only pushed changes are backmerged 48 | 'clearWorkspace': true, 49 | }, 50 | ]; 51 | 52 | const default_plugins = [ 53 | commitAnalyzer, 54 | releaseNotesGenerator, 55 | npm, 56 | git, 57 | ]; 58 | 59 | const main_plugins = [ 60 | commitAnalyzer, 61 | releaseNotesGenerator, 62 | changelog, 63 | exec, 64 | npm, 65 | git, 66 | github, 67 | backmerge, 68 | ]; 69 | 70 | // eslint-disable-next-line unicorn/prefer-module 71 | module.exports = { 72 | 'branches': [ 73 | 'main', 74 | { 75 | 'name': 'rc', 76 | 'prerelease': true, 77 | }, 78 | { 79 | 'name': 'arc-*', 80 | 'prerelease': true, 81 | }, 82 | ], 83 | plugins: branch === 'main' ? main_plugins : default_plugins, 84 | }; 85 | 86 | // eslint-disable-next-line unicorn/prefer-module 87 | console.log(module.exports); 88 | -------------------------------------------------------------------------------- /src/commands/environments/ingresses.ts: -------------------------------------------------------------------------------- 1 | import AccountUtils from '../../architect/account/account.utils'; 2 | import { EnvironmentUtils } from '../../architect/environment/environment.utils'; 3 | import BaseCommand from '../../base-command'; 4 | 5 | type CertificateResponse = { 6 | spec: { 7 | dnsNames: string[]; 8 | }; 9 | status: { 10 | notAfter: Date; 11 | notBefore: Date; 12 | renewalTime: Date; 13 | } 14 | }; 15 | 16 | export default class GetEnvironmentIngressesCmd extends BaseCommand { 17 | static description = 'List the resolvable URLs for services exposed by your environment'; 18 | static aliases = ['environment:ingresses', 'envs:ingresses', 'env:ingresses']; 19 | 20 | static flags = { 21 | ...BaseCommand.flags, 22 | ...AccountUtils.flags, 23 | }; 24 | 25 | static args = [ 26 | { 27 | sensitive: false, 28 | required: false, 29 | name: 'environment', 30 | description: 'Name to give the environment', 31 | parse: async (value: string): Promise => value.toLowerCase(), 32 | }, 33 | ]; 34 | 35 | async run(): Promise { 36 | const { args, flags } = await this.parse(GetEnvironmentIngressesCmd); 37 | 38 | const account = await AccountUtils.getAccount(this.app, flags.account); 39 | const environment = await EnvironmentUtils.getEnvironment(this.app.api, account, { environment_name: args.environment }); 40 | 41 | const { data: certificates } = await this.app.api.get(`/environments/${environment.id}/certificates`); 42 | 43 | if (certificates.length > 0) { 44 | const dns_records: string[] = []; 45 | for (const cert of certificates) { 46 | for (const dns_name of cert.spec.dnsNames 47 | .filter(dns_name => !dns_name.startsWith('env--'))) { 48 | dns_records.push(dns_name); 49 | } 50 | } 51 | this.log( 52 | dns_records 53 | .map(record => `https://${record}`) 54 | .join('\r\n'), 55 | ); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/commands/components/versions.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test'; 2 | import sinon, { SinonSpy } from 'sinon'; 3 | import AccountUtils from '../../../src/architect/account/account.utils'; 4 | import BaseTable from '../../../src/base-table'; 5 | import ComponentVersions from '../../../src/commands/components/versions'; 6 | import * as LocalizedTimestamp from '../../../src/common/utils/localized-timestamp'; 7 | import { MockArchitectApi } from '../../utils/mocks'; 8 | 9 | describe('list component versions', () => { 10 | const component = { 11 | name: 'test-component', 12 | account: { 13 | name: 'test-account', 14 | }, 15 | component_id: 'component-id', 16 | }; 17 | 18 | const date = '5/2/22, 12:38:32 AM UTC'; 19 | const component_versions = [ 20 | { 21 | tag: '0.0.1', 22 | created_at: date, 23 | }, 24 | { 25 | tag: '0.0.2', 26 | created_at: date, 27 | }, 28 | { 29 | tag: 'latest', 30 | created_at: date, 31 | }, 32 | ]; 33 | 34 | const header = { head: ['Tag', 'Created'] }; 35 | const full_table = new BaseTable(header); 36 | for (const entry of component_versions) { 37 | full_table.push([entry.tag, entry.created_at]); 38 | } 39 | 40 | new MockArchitectApi() 41 | .getComponent(component.account, component) 42 | .getComponentVersions(component, component_versions) 43 | .getTests() 44 | .stub(AccountUtils, 'getAccount', sinon.stub().returns(component.account)) 45 | .stub(LocalizedTimestamp, 'default', sinon.stub().returns(date)) 46 | .stub(ComponentVersions.prototype, 'log', sinon.fake.returns(null)) 47 | .command(['component:versions', 'test-component']) 48 | .it('list all component versions', () => { 49 | const get_account = AccountUtils.getAccount as SinonSpy; 50 | expect(get_account.getCalls().length).to.equal(1); 51 | 52 | const log_spy_list = ComponentVersions.prototype.log as SinonSpy; 53 | expect(log_spy_list.firstCall.args[0]).to.equal(full_table.toString()); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/common/plugins/plugin-utils.ts: -------------------------------------------------------------------------------- 1 | import AdmZip from 'adm-zip'; 2 | import axios from 'axios'; 3 | import * as crypto from 'crypto'; 4 | import * as fs from 'fs-extra'; 5 | import { createWriteStream } from 'fs-extra'; 6 | import { finished } from 'stream'; 7 | import * as tar from 'tar'; 8 | import { promisify } from 'util'; 9 | import { PluginArchitecture, PluginBinary, PluginBundleType, PluginPlatform } from './plugin-types'; 10 | 11 | export default class PluginUtils { 12 | static async downloadFile(url: string, location: string, sha256: string): Promise { 13 | const writer = createWriteStream(location); 14 | return axios({ 15 | method: 'get', 16 | url: url, 17 | responseType: 'stream', 18 | }).then(async response => { 19 | response.data.pipe(writer); 20 | await promisify(finished)(writer); 21 | const fileBuffer = fs.readFileSync(location); 22 | const hashSum = crypto.createHash('sha256'); 23 | hashSum.update(fileBuffer); 24 | const hex = hashSum.digest('hex'); 25 | if (hex !== sha256) { 26 | throw new Error(`Unable to verify ${url}. Please contact Architect support for help.`); 27 | } 28 | }); 29 | } 30 | 31 | static async extractFile(file: string, location: string, bundleType: PluginBundleType): Promise { 32 | if (bundleType === PluginBundleType.TAR_GZ) { 33 | await tar.extract({ file, C: location }); 34 | } else if (bundleType === PluginBundleType.ZIP) { 35 | const zip = new AdmZip(file); 36 | zip.extractAllTo(location); 37 | } 38 | } 39 | 40 | static getBinary(binaries: PluginBinary[], platform: PluginPlatform, architecture: PluginArchitecture): PluginBinary { 41 | for (const binary of binaries) { 42 | if (binary.platform === platform && binary.architecture === architecture) { 43 | return binary; 44 | } 45 | } 46 | throw new Error(`Unable to find proper binary for ${PluginPlatform[platform]}:${PluginArchitecture[architecture]}. Please contact Architect support for help.`); 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /test/integration/stateful-component/backend/src/index.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const bodyParser = require('body-parser'); 5 | const { runMigration } = require('./migration'); 6 | 7 | const start = async () => { 8 | const logger = winston.createLogger({ 9 | level: 'info', 10 | format: winston.format.json(), 11 | defaultMeta: { service: 'backend' }, 12 | transports: [ 13 | new winston.transports.Console(), 14 | new winston.transports.File({ filename: `${__dirname}/../logs/backend.log` }) 15 | ] 16 | }); 17 | 18 | const app = express(); 19 | app.use(cors()); 20 | app.use(bodyParser.urlencoded({ extended: true })); 21 | app.use(bodyParser.json()); 22 | 23 | const Sequelize = require('sequelize'); 24 | const sequelize = new Sequelize(process.env.DB_ADDR, { 25 | username: process.env.DB_USER, 26 | password: process.env.DB_PASS, 27 | retry: { 28 | max: 10, 29 | match: [ 30 | Sequelize.ConnectionError, 31 | Sequelize.ConnectionRefusedError 32 | ], 33 | } 34 | }); 35 | 36 | const SignIns = sequelize.define('name', { 37 | name: Sequelize.STRING 38 | }); 39 | 40 | await runMigration(sequelize, logger); 41 | 42 | app.get('/sign-ins', async (req, res) => { 43 | logger.info(`GET /sign-ins`); 44 | const rows = await SignIns.findAll({ 45 | order: [ 46 | ['id', 'DESC'], 47 | ] 48 | }); 49 | return res.status(200).json(rows); 50 | }); 51 | 52 | app.post('/sign-ins', async (req, res) => { 53 | try { 54 | logger.info(`POST /sign-ins`); 55 | const name = await SignIns.create({ 56 | name: req.body.name, 57 | }); 58 | return res.status(201).json(name); 59 | } catch (err) { 60 | return res.status(500); 61 | } 62 | }); 63 | 64 | app.all('*', (req, res) => { 65 | res.status(200).json([]); 66 | }); 67 | 68 | return app.listen(8080, () => { 69 | logger.info(`> Listening on port: 8080`); 70 | }); 71 | }; 72 | 73 | start(); 74 | -------------------------------------------------------------------------------- /test/dependency-manager/schema/component-builder.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test'; 2 | import path from 'path'; 3 | import { buildConfigFromYml, loadSourceYmlFromPathOrReject, parseSourceYml, Slugs } from '../../../src'; 4 | 5 | describe('component builder unit test', function () { 6 | 7 | it(`loadSourceYmlFromPathOrReject loads valid file`, async () => { 8 | const { source_path, source_yml } = loadSourceYmlFromPathOrReject(`test/mocks/superset/architect.yml`); 9 | 10 | expect(source_path).to.equal(path.resolve(`test/mocks/superset/architect.yml`)); 11 | expect(source_yml).to.contain('name: superset'); 12 | }); 13 | 14 | it(`loadSourceYmlFromPathOrReject loads valid directory`, async () => { 15 | const { source_path, source_yml } = loadSourceYmlFromPathOrReject(`test/mocks/superset`); 16 | 17 | expect(source_path).to.equal(path.resolve(`test${path.sep}mocks${path.sep}superset${path.sep}architect.yml`)); 18 | expect(source_yml).to.contain('name: superset'); 19 | }); 20 | 21 | it(`loadSourceYmlFromPathOrReject throws if given invalid directory`, async () => { 22 | expect(() => loadSourceYmlFromPathOrReject(`/non-existant/directory`)).to.throw(`Could not find architect.yml at ${path.resolve('/non-existant/directory')}`); 23 | }); 24 | 25 | it(`parseSourceYml parses yaml into object with blank fields set to null`, async () => { 26 | const { source_path, source_yml } = loadSourceYmlFromPathOrReject(`test/mocks/superset/architect.yml`); 27 | 28 | const parsed_yml = parseSourceYml(source_yml); 29 | 30 | expect((parsed_yml as any).name).to.equal('superset'); 31 | expect((parsed_yml as any).secrets.param_unset).to.be.null; // checks and makes sure we're properly parsing empty keys to 'null' 32 | }); 33 | 34 | it(`buildConfigFromYml parses yaml and builds into config`, async () => { 35 | const { source_path, source_yml } = loadSourceYmlFromPathOrReject(`test/mocks/superset/architect.yml`); 36 | 37 | const config = buildConfigFromYml(source_yml); 38 | 39 | expect(config.name).to.equal('superset'); 40 | expect(config.metadata.tag).to.equal(Slugs.DEFAULT_TAG); 41 | }); 42 | 43 | }); 44 | -------------------------------------------------------------------------------- /src/dependency-manager/config/service-config.ts: -------------------------------------------------------------------------------- 1 | import { DeploySpec } from '../spec/service-spec'; 2 | import { Dictionary } from '../utils/dictionary'; 3 | import { LivenessProbeConfig, VolumeConfig } from './common-config'; 4 | import { ResourceConfig } from './resource-config'; 5 | 6 | // Custom ingress TLS 7 | export interface IngressTlsConfig { 8 | crt: string; 9 | key: string; 10 | ca?: string; 11 | } 12 | 13 | export interface IngressConfig { 14 | enabled?: boolean; 15 | subdomain?: string; 16 | tls?: IngressTlsConfig; 17 | path?: string; 18 | ip_whitelist?: string[] | string; 19 | sticky?: boolean | string; 20 | private: boolean; 21 | 22 | // Context 23 | consumers?: string[]; 24 | dns_zone?: string; 25 | host?: null | string; 26 | port?: number | string; 27 | protocol?: string; 28 | username?: null | string; 29 | password?: null | string; 30 | url?: string; 31 | } 32 | 33 | export interface ScalingMetricsConfig { 34 | cpu?: number | string; // TODO:290:number 35 | memory?: number | string; 36 | } 37 | 38 | export interface ScalingConfig { 39 | min_replicas: number | string; // TODO:290:number 40 | max_replicas: number | string; // TODO:290:number 41 | metrics: ScalingMetricsConfig; 42 | } 43 | 44 | export interface ServiceInterfaceConfig { 45 | description?: string; 46 | host?: null | string; // TODO:290:string 47 | port: number | string; // TODO:290:number 48 | protocol?: string; 49 | username?: null | string; // TODO:290:string 50 | password?: null | string; // TODO:290:string 51 | url?: string; 52 | sticky?: boolean | string; 53 | path?: string; 54 | ingress?: IngressConfig; 55 | } 56 | 57 | export interface DatabaseConfig { 58 | type: string; 59 | connection_string?: string; 60 | url?: string; 61 | } 62 | 63 | export interface ServiceConfig extends ResourceConfig { 64 | enabled: boolean; 65 | debug?: ServiceConfig; 66 | interfaces: Dictionary; 67 | liveness_probe?: LivenessProbeConfig; 68 | volumes: Dictionary; 69 | replicas: number | string; // TODO:290:number 70 | scaling?: ScalingConfig; 71 | deploy?: DeploySpec; 72 | termination_grace_period?: string; 73 | } 74 | -------------------------------------------------------------------------------- /src/commands/unlink.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import path from 'path'; 3 | import untildify from 'untildify'; 4 | import { buildSpecFromPath } from '../'; 5 | import BaseCommand from '../base-command'; 6 | import { booleanString } from '../common/utils/oclif'; 7 | 8 | export default class Unlink extends BaseCommand { 9 | async auth_required(): Promise { 10 | return false; 11 | } 12 | 13 | static description = 'Unlink a component from the host by path or name'; 14 | static examples = [ 15 | 'architect unlink', 16 | 'architect unlink -p ../architect.yml', 17 | 'architect unlink -p mycomponent', 18 | ]; 19 | static flags = { 20 | ...BaseCommand.flags, 21 | all: booleanString({ 22 | description: 'Unlink all components registered locally', 23 | sensitive: false, 24 | default: false, 25 | }), 26 | }; 27 | 28 | static args = [{ 29 | sensitive: false, 30 | name: 'componentPathOrName', 31 | char: 'p', 32 | default: '.', 33 | parse: async (value: string): Promise => value.toLowerCase(), 34 | required: false, 35 | }]; 36 | 37 | async run(): Promise { 38 | const { args, flags } = await this.parse(Unlink); 39 | 40 | if (flags.all) { 41 | this.app.unlinkAllComponents(); 42 | this.log(chalk.green('Successfully purged all linked components')); 43 | return; 44 | } 45 | 46 | if (args.componentPathOrName === '.' || args.componentPathOrName.toLowerCase().endsWith('architect.yml')) { 47 | const component_path = path.resolve(untildify(args.componentPathOrName)); 48 | try { 49 | const component_config = buildSpecFromPath(component_path); 50 | args.componentPathOrName = component_config.name; 51 | } catch (err: any) { 52 | this.log(chalk.red('Unable to locate architect.yml file')); 53 | return; 54 | } 55 | } 56 | 57 | const removedComponentName = this.app.unlinkComponent(args.componentPathOrName); 58 | if (!removedComponentName) { 59 | this.log(chalk.red(`No linked component found matching, ${args.componentPathOrName}`)); 60 | } else { 61 | this.log(chalk.green(`Successfully unlinked ${removedComponentName}`)); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/commands/clusters/index.ts: -------------------------------------------------------------------------------- 1 | import Account from '../../architect/account/account.entity'; 2 | import AccountUtils from '../../architect/account/account.utils'; 3 | import BaseCommand from '../../base-command'; 4 | import Table from '../../base-table'; 5 | import localizedTimestamp from '../../common/utils/localized-timestamp'; 6 | 7 | export default class Clusters extends BaseCommand { 8 | static aliases = ['cluster', 'cluster:search', 'cluster:list', 'clusters:search', 'clusters:list']; 9 | static description = 'Search for clusters on Architect Cloud'; 10 | static examples = [ 11 | 'architect clusters', 12 | 'architect clusters --account=myaccount mycluster', 13 | ]; 14 | static flags = { 15 | ...BaseCommand.flags, 16 | ...AccountUtils.flags, 17 | }; 18 | 19 | static args = [{ 20 | sensitive: false, 21 | name: 'query', 22 | description: 'Search query used to filter results', 23 | required: false, 24 | }]; 25 | 26 | async run(): Promise { 27 | const { args, flags } = await this.parse(Clusters); 28 | 29 | let account: Account | undefined; 30 | if (flags.account) { 31 | account = await AccountUtils.getAccount(this.app, flags.account); 32 | } 33 | 34 | const params = { 35 | q: args.query || '', 36 | account_id: account?.id, 37 | }; 38 | 39 | const { data: { rows: clusters } } = await this.app.api.get(`/clusters`, { params }); 40 | 41 | if (clusters.length === 0) { 42 | if (args.query) { 43 | this.log(`No clusters found matching ${args.query}.`); 44 | } else { 45 | this.log('You have not configured any clusters yet. Use `architect cluster:create` to set up your first one.'); 46 | } 47 | return; 48 | } 49 | 50 | const table = new Table({ head: ['Name', 'Account', 'Host', 'Type', 'Credentials', 'Created', 'Updated'] }); 51 | for (const row of clusters) { 52 | table.push([ 53 | row.name, 54 | row.account.name, 55 | row.properties.host, 56 | row.type, 57 | 'Encrypted on Server', 58 | localizedTimestamp(row.created_at), 59 | localizedTimestamp(row.updated_at), 60 | ]); 61 | } 62 | 63 | this.log(table.toString()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/commands/environments/index.ts: -------------------------------------------------------------------------------- 1 | import Account from '../../architect/account/account.entity'; 2 | import AccountUtils from '../../architect/account/account.utils'; 3 | import BaseCommand from '../../base-command'; 4 | import Table from '../../base-table'; 5 | import localizedTimestamp from '../../common/utils/localized-timestamp'; 6 | 7 | export default class Environments extends BaseCommand { 8 | static aliases = ['environments', 'envs', 'env', 'environments:search', 'envs:search', 'env:search', 'environments:list', 'envs:list', 'env:list']; 9 | static description = 'Search environments you have access to'; 10 | static examples = [ 11 | 'architect environments', 12 | 'architect environments --account=myaccount', 13 | 'architect environments myenvironment', 14 | ]; 15 | static flags = { 16 | ...BaseCommand.flags, 17 | ...AccountUtils.flags, 18 | }; 19 | 20 | static args = [{ 21 | sensitive: false, 22 | name: 'query', 23 | description: 'Search term used to filter the results', 24 | }]; 25 | 26 | async run(): Promise { 27 | const { args, flags } = await this.parse(Environments); 28 | 29 | let account: Account | undefined; 30 | if (flags.account) { 31 | account = await AccountUtils.getAccount(this.app, flags.account); 32 | } 33 | 34 | const params = { 35 | q: args.query || '', 36 | account_id: account?.id, 37 | }; 38 | 39 | const { data: { rows: environments } } = await this.app.api.get(`/environments`, { params }); 40 | 41 | if (environments.length === 0) { 42 | if (args.query) { 43 | this.log(`No environments found matching ${args.query}.`); 44 | } else { 45 | this.log('You have not configured any environments yet. Use `architect environments:create` to set up your first one.'); 46 | } 47 | return; 48 | } 49 | 50 | const table = new Table({ head: ['Name', 'Account', 'Namespace', 'Created', 'Updated'] }); 51 | for (const env of environments) { 52 | table.push([ 53 | env.name, 54 | env.account.name, 55 | env.namespace, 56 | localizedTimestamp(env.created_at), 57 | localizedTimestamp(env.updated_at), 58 | ]); 59 | } 60 | 61 | this.log(table.toString()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/dependency-manager/utils/files.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import yaml from 'js-yaml'; 3 | import path from 'path'; 4 | import untildify from 'untildify'; 5 | import { ArchitectError } from './errors'; 6 | import { ParsedYaml } from './types'; 7 | 8 | // https://stackoverflow.com/questions/4253367/how-to-escape-a-json-string-containing-newline-characters-using-javascript 9 | const escape = (str: string): string => { 10 | return str 11 | .replace(/\\/g, '\\\\') 12 | .replace(/"/g, '\\"') 13 | .replace(/\//g, '\\/') 14 | .replace(/[\b]/g, '\\b') 15 | .replace(/\f/g, '\\f') 16 | .replace(/\n/g, '\\n') 17 | .replace(/\r/g, '\\r') 18 | .replace(/\t/g, '\\t'); 19 | }; 20 | 21 | const escapeEnvironmentInterpolation = (str: string): string => { 22 | return str.replace(/\${([^{].*})/g, '$$$$$${$1'); 23 | }; 24 | 25 | export const readFile = (any_or_path: any, config_path: string): string => { 26 | const file_path = untildify(any_or_path.slice('file:'.length)); 27 | const res = fs.readFileSync(path.resolve(path.dirname(config_path), file_path), 'utf-8'); 28 | return escape(escapeEnvironmentInterpolation(res.trim())); 29 | }; 30 | 31 | export const insertFileDataFromRefs = (file_contents: string, config_path: string): string => { 32 | let updated_file_contents = file_contents; 33 | const file_regex = new RegExp('^(?!.*"extends)[a-zA-Z0-9_"\\s:]*(file:.*\\..*)(",|")$', 'gm'); 34 | let matches; 35 | while ((matches = file_regex.exec(updated_file_contents)) !== null) { 36 | updated_file_contents = updated_file_contents.replace(matches[1], readFile(matches[1], config_path)); 37 | } 38 | return updated_file_contents; 39 | }; 40 | 41 | export const replaceFileReference = (parsed_yml: ParsedYaml, config_path: string): string => { 42 | if ((parsed_yml || '').toString().trim().length === 0) { 43 | throw new ArchitectError(`The file at ${config_path} is empty. For help getting started take a look at our documentation here: https://docs.architect.io/reference/architect-yml`); 44 | } 45 | const source_as_json = JSON.stringify(parsed_yml, null, 2); 46 | const replaced_source = insertFileDataFromRefs(source_as_json, config_path); 47 | const replaced_object = JSON.parse(replaced_source); 48 | 49 | return yaml.dump(replaced_object); 50 | }; 51 | -------------------------------------------------------------------------------- /test/integration/stateless-component/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import { Button, Container, Grid, Typography } from '@material-ui/core'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Head from 'next/head'; 4 | import React from 'react'; 5 | 6 | const useStyles = makeStyles(theme => ({ 7 | container: { 8 | textAlign: 'center', 9 | height: '100%', 10 | }, 11 | 12 | header: { 13 | fontSize: 'calc(10px + 2vmin)', 14 | color: 'white', 15 | }, 16 | 17 | link: { 18 | color: '#61dafb', 19 | }, 20 | 21 | logo: { 22 | height: '40vmin', 23 | pointerEvents: 'none', 24 | 25 | '@media (prefers-reduced-motion: no-preference)': { 26 | '&': { 27 | animation: 'logo-spin infinite 20s linear', 28 | } 29 | } 30 | }, 31 | 32 | '@keyframes logo-spin': { 33 | from: { 34 | transform: 'rotate(0deg)', 35 | }, 36 | to: { 37 | transform: 'rotate(360deg)', 38 | } 39 | } 40 | })); 41 | 42 | const Home = () => { 43 | const classes = useStyles(); 44 | const [echoRes, setEchoRes] = React.useState(''); 45 | 46 | const onClick = () => { 47 | fetch('/hello') 48 | .then(res => res.text()) 49 | .then(data => { 50 | setEchoRes(data); 51 | }); 52 | }; 53 | 54 | return ( 55 | 56 | 57 | Stateless components | Architect examples 58 | 59 | 60 | 61 | 62 | 63 |
64 | logo 65 |
66 | 67 | 76 | 77 | Echo response 78 | 79 |
{echoRes}
80 |
81 |
82 |
83 | ); 84 | }; 85 | 86 | export default Home 87 | -------------------------------------------------------------------------------- /src/dependency-manager/spec/transform/common-transform.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor, plainToInstance, TransformFnParams } from 'class-transformer'; 2 | import stringArgv from 'string-argv'; 3 | import { LivenessProbeConfig, VolumeConfig } from '../../config/common-config'; 4 | import { LivenessProbeSpec, VolumeSpec } from '../common-spec'; 5 | 6 | export const transformLivenessProbeSpecCommand = function (command: string[] | string | undefined): string[] | undefined { 7 | if (!command) { 8 | return undefined; 9 | } 10 | if (typeof command === 'string') { 11 | return stringArgv(command); 12 | } else { 13 | return command; 14 | } 15 | }; 16 | 17 | export const transformLivenessProbeSpec = function (liveness_probe: LivenessProbeSpec | undefined): LivenessProbeConfig | undefined { 18 | if (!liveness_probe || Object.keys(liveness_probe).length === 0) { 19 | return undefined; 20 | } 21 | 22 | return { 23 | success_threshold: liveness_probe.success_threshold || LivenessProbeSpec.default_success_threshold, 24 | failure_threshold: liveness_probe.failure_threshold || LivenessProbeSpec.default_failure_threshold, 25 | timeout: liveness_probe.timeout || LivenessProbeSpec.default_timeout, 26 | interval: liveness_probe.interval || LivenessProbeSpec.default_interval, 27 | initial_delay: liveness_probe.initial_delay || LivenessProbeSpec.default_initial_delay, 28 | path: liveness_probe.path, 29 | command: transformLivenessProbeSpecCommand(liveness_probe.command), 30 | port: liveness_probe.port, 31 | }; 32 | }; 33 | 34 | export const transformVolumeSpec = (key: string, volume: VolumeSpec | string): VolumeConfig => { 35 | if (volume instanceof Object) { 36 | return { 37 | mount_path: volume.mount_path, 38 | host_path: volume.host_path, 39 | key: volume.key, 40 | description: volume.description, 41 | readonly: volume.readonly, 42 | }; 43 | } else { 44 | return { 45 | mount_path: volume, 46 | }; 47 | } 48 | }; 49 | 50 | export const transformObject = (cls: ClassConstructor): (params: TransformFnParams) => any => { 51 | return ({ value }) => { 52 | for (const [k, v] of Object.entries(value)) { 53 | value[k] = v instanceof Object ? plainToInstance(cls, v) : v; 54 | } 55 | return value; 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { run } from '@oclif/core'; 2 | export * from './common/docker-compose/converter'; 3 | export * from './dependency-manager/config/common-config'; 4 | export * from './dependency-manager/config/component-config'; 5 | export * from './dependency-manager/config/component-context'; 6 | export * from './dependency-manager/config/resource-config'; 7 | export * from './dependency-manager/config/service-config'; 8 | export * from './dependency-manager/config/task-config'; 9 | export * from './dependency-manager/graph'; 10 | export * from './dependency-manager/graph/edge'; 11 | export * from './dependency-manager/graph/edge/ingress'; 12 | export * from './dependency-manager/graph/edge/ingress-consumer'; 13 | export * from './dependency-manager/graph/edge/service'; 14 | export * from './dependency-manager/graph/node'; 15 | export * from './dependency-manager/graph/node/gateway'; 16 | export * from './dependency-manager/graph/node/service'; 17 | export * from './dependency-manager/graph/node/task'; 18 | export * from './dependency-manager/spec/common-spec'; 19 | export * from './dependency-manager/spec/component-spec'; 20 | export * from './dependency-manager/spec/resource-spec'; 21 | export * from './dependency-manager/spec/secret-spec'; 22 | export * from './dependency-manager/spec/service-spec'; 23 | export * from './dependency-manager/spec/task-spec'; 24 | export * from './dependency-manager/spec/transform/component-transform'; 25 | export * from './dependency-manager/spec/transform/resource-transform'; 26 | export * from './dependency-manager/spec/transform/service-transform'; 27 | export * from './dependency-manager/spec/transform/task-transform'; 28 | export * from './dependency-manager/spec/utils/component-builder'; 29 | export * from './dependency-manager/spec/utils/json-schema'; 30 | export * from './dependency-manager/spec/utils/slugs'; 31 | export * from './dependency-manager/spec/utils/spec-merge'; 32 | export * from './dependency-manager/spec/utils/spec-validator'; 33 | export * from './dependency-manager/utils/dictionary'; 34 | export * from './dependency-manager/utils/errors'; 35 | export * from './dependency-manager/utils/refs'; 36 | export * from './dependency-manager/utils/types'; 37 | 38 | import DependencyManager from './dependency-manager/manager'; 39 | 40 | export default DependencyManager; 41 | 42 | -------------------------------------------------------------------------------- /src/dependency-manager/utils/refs.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | export class Refs { 4 | private static HASH_LENGTH = 8; 5 | public static DEFAULT_MAX_LENGTH = 63; 6 | 7 | public static safeRef(ref: string, max_length?: number): string; 8 | public static safeRef(ref: string, seed?: string, max_length?: number): string; 9 | public static safeRef(ref: string, seed?: string | number, max_length: number = Refs.DEFAULT_MAX_LENGTH): string { 10 | if (typeof seed === 'number') { 11 | max_length = seed; 12 | } 13 | if (typeof seed === 'number' || !seed) { 14 | seed = ref; 15 | } 16 | 17 | if (max_length < Refs.HASH_LENGTH) { 18 | throw new Error('Max length cannot be less than hash length'); 19 | } 20 | 21 | const sanitized_ref = ref.replace(/[^\dA-Za-z-]/g, '-'); 22 | const truncated_ref = sanitized_ref.substring(0, (max_length - 1) - Refs.HASH_LENGTH); 23 | const hash = Refs.toDigest(seed).substring(0, Refs.HASH_LENGTH); 24 | 25 | return `${truncated_ref}-${hash}`; 26 | } 27 | 28 | public static trimSafeRef(ref: string, max_length = Refs.DEFAULT_MAX_LENGTH, prefix = '', suffix = ''): string { 29 | const split = ref.split('-'); 30 | const hash = split.pop(); 31 | if (!hash || hash.length !== Refs.HASH_LENGTH) { 32 | throw new Error(`Not a valid ref: ${ref}`); 33 | } 34 | 35 | const target_length = max_length - (hash.length + 1 + suffix.length); 36 | if (target_length < 0) { 37 | throw new Error(`Cannot trim ref to length: ${max_length}`); 38 | } 39 | 40 | const trimmed_name = `${prefix}${split.join('-')}`.substring(0, target_length); 41 | return `${trimmed_name}${suffix}-${hash}`; 42 | } 43 | 44 | /** 45 | * This is not a standard base64 md5 hash as we lowercase and replace punctuation 46 | * This method should not be used for anything beyond conveniently adding entropy to the safeRef. 47 | * @param uri 48 | */ 49 | private static toDigest(uri: string): string { 50 | return crypto.createHash('md5').update(uri) 51 | .digest('base64') // base64 adds entropy in a more compact string 52 | .toLowerCase() // we need to makes everything lower which unfortunately removes some entropy 53 | .replace(/[+/=\\]/g, ''); // we also remove occurances of slash, plus, and equals to make url-safe 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/components/index.ts: -------------------------------------------------------------------------------- 1 | import Account from '../../architect/account/account.entity'; 2 | import AccountUtils from '../../architect/account/account.utils'; 3 | import { Component } from '../../architect/component/component.entity'; 4 | import BaseCommand from '../../base-command'; 5 | import Table from '../../base-table'; 6 | import localizedTimestamp from '../../common/utils/localized-timestamp'; 7 | 8 | export default class Components extends BaseCommand { 9 | static aliases = ['components', 'components:search', 'components:list', 'component:search', 'component:search', 'component:list']; 10 | static description = 'Search components you have access to'; 11 | static examples = [ 12 | 'architect components', 13 | 'architect components --account=myaccount', 14 | 'architect components mycomponent', 15 | ]; 16 | static flags = { 17 | ...BaseCommand.flags, 18 | ...AccountUtils.flags, 19 | }; 20 | 21 | static args = [{ 22 | sensitive: false, 23 | name: 'query', 24 | description: 'Search term used to filter the results', 25 | }]; 26 | 27 | async run(): Promise { 28 | const { args, flags } = await this.parse(Components); 29 | 30 | let account: Account | undefined; 31 | if (flags.account) { 32 | account = await AccountUtils.getAccount(this.app, flags.account); 33 | } 34 | 35 | const params = { 36 | q: args.query || '', 37 | account_id: account?.id, 38 | }; 39 | 40 | let { data: { rows: components } } = await this.app.api.get(`/components`, { params }); 41 | components = components.filter((c: Component) => c.account); 42 | 43 | if (components.length === 0) { 44 | if (args.query) { 45 | this.log(`No components found matching ${args.query}.`); 46 | } else { 47 | this.log('You have not registered any components yet. Use `architect register` to set up your first one.'); 48 | } 49 | return; 50 | } 51 | 52 | const table = new Table({ head: ['Name', 'Account', 'Created', 'Updated'] }); 53 | for (const component of components.sort((c1: Component, c2: Component) => c1.name.localeCompare(c2.name))) { 54 | table.push([ 55 | component.name, 56 | component.account.name, 57 | localizedTimestamp(component.created_at), 58 | localizedTimestamp(component.updated_at), 59 | ]); 60 | } 61 | 62 | this.log(table.toString()); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/commands/components/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test'; 2 | import sinon, { SinonSpy } from 'sinon'; 3 | import BaseTable from '../../../src/base-table'; 4 | import Components from '../../../src/commands/components/index'; 5 | import * as LocalizedTimestamp from '../../../src/common/utils/localized-timestamp'; 6 | import { MockArchitectApi } from '../../utils/mocks'; 7 | 8 | describe('list components', () => { 9 | const date = '5/2/22, 12:38:32 AM UTC'; 10 | const components = [ 11 | { 12 | name: 'another', 13 | account: { 14 | name: 'account2' 15 | }, 16 | created_at: date, 17 | updated_at: date, 18 | }, 19 | { 20 | name: 'component1', 21 | account: { 22 | name: 'account1' 23 | }, 24 | created_at: date, 25 | updated_at: date, 26 | }, 27 | { 28 | name: 'component2', 29 | account: { 30 | name: 'account1' 31 | }, 32 | created_at: date, 33 | updated_at: date, 34 | }, 35 | ]; 36 | 37 | const header = { head: ['Name', 'Account', 'Created', 'Updated'] }; 38 | const full_table = new BaseTable(header); 39 | for (const entry of components) { 40 | full_table.push([entry.name, entry.account.name, entry.created_at, entry.updated_at]); 41 | } 42 | const query_table = new BaseTable(header); 43 | query_table.push([components[2].name, components[2].account.name, components[2].created_at, components[2].updated_at]); 44 | 45 | new MockArchitectApi() 46 | .getComponents(components) 47 | .getTests() 48 | .stub(Components.prototype, 'log', sinon.fake.returns(null)) 49 | .stub(LocalizedTimestamp, 'default', sinon.stub().returns(date)) 50 | .command(['components']) 51 | .it('list all components', () => { 52 | const log_spy_list = Components.prototype.log as SinonSpy; 53 | expect(log_spy_list.firstCall.args[0]).to.equal(full_table.toString()); 54 | }); 55 | 56 | new MockArchitectApi() 57 | .getComponents([components[2]], { query: 'another' }) 58 | .getTests() 59 | .stub(Components.prototype, 'log', sinon.fake.returns(null)) 60 | .stub(LocalizedTimestamp, 'default', sinon.stub().returns(date)) 61 | .command(['components', 'another']) 62 | .it('list queried components', () => { 63 | const log_spy_list = Components.prototype.log as SinonSpy; 64 | expect(log_spy_list.firstCall.args[0]).to.equal(query_table.toString()); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/app-config/callback-server.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as http from 'http'; 3 | import path from 'path'; 4 | import { ArchitectError } from '../dependency-manager/utils/errors'; 5 | 6 | export default class CallbackServer { 7 | async listenForCallback(port: number): Promise { 8 | const [success_file, failure_file] = await Promise.all([this.get_success_file(), this.get_failure_file()]); 9 | 10 | return new Promise((resolve, reject) => { 11 | const server = http.createServer(); 12 | 13 | server.on('request', async (req, res) => { 14 | try { 15 | const queryObject = new URL(req.url, 'http://localhost').searchParams; 16 | if (queryObject.has('error')) { 17 | res.writeHead(400, { 'Content-Type': 'text/html' }); 18 | const failure_html = failure_file.toString().replace('%%FAILURE_MESSAGE%%', (queryObject.get('error_description') as string)); 19 | res.end(failure_html); 20 | reject(new ArchitectError('Login failed: ' + queryObject.get('error_description'))); 21 | } else { 22 | res.writeHead(200, { 'Content-Type': 'text/html' }); 23 | res.end(success_file); 24 | resolve(queryObject.get('code') as string); 25 | } 26 | } finally { 27 | server.close(); 28 | } 29 | }); 30 | 31 | server.on('error', async (err) => { 32 | reject(err); 33 | }); 34 | 35 | server.listen(port); 36 | }); 37 | } 38 | 39 | async get_success_file(): Promise { 40 | // eslint-disable-next-line unicorn/prefer-module 41 | const success_path = path.join(path.dirname(fs.realpathSync(__filename)), '../static/login_callback_success.html'); 42 | return new Promise((resolve, reject) => { 43 | fs.readFile(success_path, function (err, html) { 44 | if (err) { 45 | reject(err); 46 | } 47 | resolve(html); 48 | }); 49 | }); 50 | } 51 | 52 | async get_failure_file(): Promise { 53 | return new Promise((resolve, reject) => { 54 | // eslint-disable-next-line unicorn/prefer-module 55 | const failure_path = path.join(path.dirname(fs.realpathSync(__filename)), '../static/login_callback_failure.html'); 56 | fs.readFile(failure_path, function (err, html) { 57 | if (err) { 58 | reject(err); 59 | } 60 | resolve(html); 61 | }); 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/commands/destroy.ts: -------------------------------------------------------------------------------- 1 | import { CliUx, Flags } from '@oclif/core'; 2 | import chalk from 'chalk'; 3 | import AccountUtils from '../architect/account/account.utils'; 4 | import Deployment from '../architect/deployment/deployment.entity'; 5 | import { EnvironmentUtils, GetEnvironmentOptions } from '../architect/environment/environment.utils'; 6 | import PipelineUtils from '../architect/pipeline/pipeline.utils'; 7 | import { DeployCommand } from './deploy'; 8 | 9 | export default class Destroy extends DeployCommand { 10 | async auth_required(): Promise { 11 | return true; 12 | } 13 | 14 | static description = 'Destroy components from an environment'; 15 | 16 | static examples = [ 17 | 'architect destroy --account=myaccount --auto-approve', 18 | 'architect destroy --account=myaccount --environment=myenvironment --auto-approve', 19 | ]; 20 | 21 | static args = []; 22 | static flags = { 23 | ...DeployCommand.flags, 24 | ...AccountUtils.flags, 25 | ...EnvironmentUtils.flags, 26 | components: Flags.string({ 27 | char: 'c', 28 | description: 'Component(s) to destroy', 29 | multiple: true, 30 | sensitive: false, 31 | }), 32 | }; 33 | 34 | async run(): Promise { 35 | const { flags } = await this.parse(Destroy); 36 | 37 | const account = await AccountUtils.getAccount(this.app, flags.account); 38 | const get_environment_options: GetEnvironmentOptions = { environment_name: flags.environment }; 39 | const environment = await EnvironmentUtils.getEnvironment(this.app.api, account, get_environment_options); 40 | 41 | CliUx.ux.action.start(chalk.blue('Creating pipeline')); 42 | let instance_ids; 43 | if (flags.components) { 44 | const { data: instances_to_destroy } = await this.app.api.get(`/environments/${environment.id}/instances`, { params: { component_versions: flags.components } }); 45 | instance_ids = instances_to_destroy.map((instance: Deployment) => instance.instance_id); 46 | } 47 | const { data: pipeline } = await this.app.api.delete(`/environments/${environment.id}/instances`, { data: { instance_ids } }); 48 | CliUx.ux.action.stop(); 49 | 50 | const approved = await this.approvePipeline(pipeline); 51 | if (!approved) { 52 | return; 53 | } 54 | 55 | CliUx.ux.action.start(chalk.blue('Deploying')); 56 | await PipelineUtils.pollPipeline(this.app, pipeline.id); 57 | this.log(chalk.green(`Deployed`)); 58 | CliUx.ux.action.stop(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/common/dependency-manager/validation.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { Dictionary, ValidationError, ValidationErrors } from '../../'; 3 | 4 | export const prettyValidationErrors = (error: ValidationErrors): void => { 5 | if (!error.file) { 6 | console.error(chalk.red(error.name)); 7 | console.error(chalk.red(error.message)); 8 | return; 9 | } 10 | const errors = JSON.parse(error.message) as ValidationError[]; 11 | 12 | const errors_row_map: Dictionary = {}; 13 | let min_row = Number.POSITIVE_INFINITY; 14 | let max_row = Number.NEGATIVE_INFINITY; 15 | let missing_line_numbers = false; 16 | for (const error of errors) { 17 | if (error.start && error.end) { 18 | // TODO handle multiple errors on one row? 19 | errors_row_map[error.start.row] = error; 20 | if (error.start.row < min_row) { 21 | min_row = error.start.row; 22 | } 23 | if (error.start.row > max_row) { 24 | max_row = error.start.row; 25 | } 26 | } else { 27 | missing_line_numbers = true; 28 | } 29 | } 30 | 31 | if (missing_line_numbers) { 32 | console.error(chalk.red(error.name)); 33 | console.error(chalk.red(error.message)); 34 | return; 35 | } 36 | 37 | min_row = Math.max(min_row - 4, 0); 38 | max_row += 3; 39 | 40 | const res = []; 41 | let line_number = min_row + 1; 42 | const lines = error.file.contents.split('\n').slice(min_row, max_row); 43 | const lines_length = lines.length; 44 | const max_number_length = `${min_row + lines_length}`.length; 45 | for (const line of lines) { 46 | const error = errors_row_map[line_number]; 47 | 48 | const line_number_space = (max_number_length - `${line_number}`.length); 49 | 50 | let number_line = error ? chalk.red('›') + ' ' : ' '; 51 | number_line += chalk.gray(`${' '.repeat(line_number_space)}${line_number} | `); 52 | number_line += chalk.cyan(line); 53 | res.push(number_line); 54 | 55 | if (error?.start && error?.end) { 56 | let error_line = chalk.gray(`${' '.repeat(max_number_length + 2)} | `); 57 | error_line += ' '.repeat(error.start.column - 1); 58 | error_line += chalk.red('﹋'.repeat(Math.max(((error.end.column - error.start.column) + 1) / 2, 1))); 59 | error_line += ' '; 60 | error_line += chalk.red(error.message); 61 | res.push(error_line); 62 | } 63 | 64 | line_number += 1; 65 | } 66 | 67 | console.error(chalk.red(error.name)); 68 | console.error(res.join('\n')); 69 | }; 70 | -------------------------------------------------------------------------------- /src/common/docker-compose/template.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from '../../dependency-manager/utils/dictionary'; 2 | 3 | interface XBakeConfig { 4 | platforms: string[]; 5 | 'cache-from'?: string | string[]; 6 | 'cache-to'?: string | string[]; 7 | pull: boolean; 8 | } 9 | 10 | export interface DockerServiceBuild { 11 | context?: string; 12 | args?: string[] | { [s: string]: string }; 13 | dockerfile?: string; 14 | target?: string; 15 | tags?: string[]; 16 | 'x-bake'?: XBakeConfig; 17 | labels?: string[]; 18 | } 19 | 20 | export interface DockerComposeVolume { 21 | type?: string; 22 | source?: string; 23 | target: string; 24 | read_only?: boolean; 25 | } 26 | 27 | export interface DockerComposeInterface { 28 | target: string | number; 29 | published: string | number; 30 | protocol?: string; 31 | mode?: string; 32 | } 33 | 34 | export interface DockerComposeDeploy { 35 | replicas?: number; 36 | resources?: { limits: { cpus?: string; memory?: string } }; 37 | } 38 | 39 | export interface DockerComposeHealthCheck { 40 | test: string[]; 41 | interval: string; 42 | timeout?: string; 43 | retries?: number; 44 | start_period?: string; 45 | } 46 | 47 | export interface DockerService { 48 | ports?: string[] | DockerComposeInterface[]; 49 | image?: string; 50 | environment?: { [key: string]: any }; 51 | depends_on?: Dictionary<{ condition: string }> | string[]; 52 | build?: DockerServiceBuild; 53 | volumes?: string[] | DockerComposeVolume[]; 54 | command?: string[]; 55 | restart?: string; 56 | entrypoint?: string[]; 57 | dns_search?: string | string[]; 58 | logging?: { driver?: string }; 59 | external_links?: string[]; 60 | deploy?: DockerComposeDeploy; 61 | extra_hosts?: string[]; 62 | labels?: string[]; 63 | healthcheck?: DockerComposeHealthCheck; 64 | stop_grace_period?: string; 65 | } 66 | 67 | export default interface DockerComposeTemplate { 68 | version: '3'; 69 | services: { [key: string]: DockerService }; 70 | volumes: { [key: string]: { external?: boolean } }; 71 | } 72 | 73 | export interface DockerInspectHealth { 74 | Status: string; 75 | FailingStreak: number; 76 | Log: { 77 | Start: string, 78 | End: string, 79 | ExitCode: number, 80 | Output: string 81 | }[] 82 | } 83 | 84 | export interface DockerInspect { 85 | Id: string, 86 | State: { 87 | Status: string, 88 | Health: DockerInspectHealth, 89 | ExitCode: number 90 | StartedAt: string; 91 | }, 92 | Name: string, 93 | Config: { 94 | Labels: { [key: string]: string } 95 | } 96 | } 97 | 98 | -------------------------------------------------------------------------------- /test/commands/link/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import chalk from 'chalk'; 3 | import fs from 'fs-extra'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | import sinon, { SinonSpy } from 'sinon'; 7 | import AppConfig from '../../../src/app-config/config'; 8 | import AppService from '../../../src/app-config/service'; 9 | import Link from '../../../src/commands/link'; 10 | import ARCHITECTPATHS from '../../../src/paths'; 11 | 12 | describe('link', () => { 13 | let tmp_dir = os.tmpdir(); 14 | const linked_components_file = path.join(tmp_dir, ARCHITECTPATHS.LINKED_COMPONENT_MAP_FILENAME); 15 | const component_path = path.join(__dirname, '../../mocks/superset/').replace(/\/$/gi, '').replace(/\\$/gi, ''); 16 | const bad_path = path.join(__dirname, '../examples').toLowerCase(); 17 | 18 | beforeEach(() => { 19 | // Stub the log_level 20 | const config = new AppConfig('', { 21 | log_level: 'info', 22 | }); 23 | const tmp_linked_components_file = path.join(tmp_dir, ARCHITECTPATHS.LINKED_COMPONENT_MAP_FILENAME); 24 | fs.writeJSONSync(tmp_linked_components_file, {}); 25 | const tmp_config_file = path.join(tmp_dir, ARCHITECTPATHS.CLI_CONFIG_FILENAME); 26 | fs.writeJSONSync(tmp_config_file, config); 27 | const app_config_stub = sinon.stub().returns(new AppService(tmp_dir, '0.0.1')); 28 | sinon.replace(AppService, 'create', app_config_stub); 29 | }); 30 | 31 | afterEach(() => { 32 | sinon.restore(); 33 | }); 34 | 35 | test 36 | .command(['link', bad_path]) 37 | .catch(err => { 38 | expect(err.message).to.equal(`Could not find architect.yml at ${bad_path}`); 39 | }) 40 | .it('should fail link without component config', () => { 41 | if (fs.existsSync(linked_components_file)) { 42 | const linked_components = fs.readJSONSync(linked_components_file); 43 | expect(linked_components).not.to.have.property('superset'); 44 | } 45 | }); 46 | 47 | test 48 | .stub(Link.prototype, 'log', sinon.fake.returns(null)) 49 | .command(['link', component_path]) 50 | .it('link a component', () => { 51 | const log_spy = Link.prototype.log as SinonSpy; 52 | expect(log_spy.calledOnce).to.equal(true); 53 | expect(log_spy.firstCall.args[0]).to.equal(`Successfully linked ${chalk.green('superset')} to local system at ${chalk.green(component_path)}.`); 54 | 55 | expect(fs.existsSync(linked_components_file)).to.be.true; 56 | const linked_components = fs.readJSONSync(linked_components_file); 57 | expect(linked_components).to.have.property('superset', component_path); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/integration/stateless-component/public/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/integration/stateful-component/frontend/public/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/static/doctor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 34 | 35 | 36 | 37 | ​ 38 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /src/architect/secret/secret.utils.ts: -------------------------------------------------------------------------------- 1 | import AppService from '../../app-config/service'; 2 | import Account from '../account/account.entity'; 3 | import Cluster from '../cluster/cluster.entity'; 4 | import ClusterUtils from '../cluster/cluster.utils'; 5 | import Environment from '../environment/environment.entity'; 6 | import { EnvironmentUtils, GetEnvironmentOptions } from '../environment/environment.utils'; 7 | 8 | export interface Secret { 9 | scope: string; 10 | key: string; 11 | value: string | number | boolean; 12 | } 13 | 14 | export interface AccountSecret extends Secret { 15 | account: Account; 16 | } 17 | 18 | export interface ClusterSecret extends Secret { 19 | cluster: Cluster; 20 | } 21 | 22 | export interface EnvironmentSecret extends Secret { 23 | environment: Environment; 24 | } 25 | 26 | export interface SecretOptions { 27 | cluster_name?: string; 28 | environment_name?: string; 29 | } 30 | 31 | export default class SecretUtils { 32 | static async getSecrets(app: AppService, account: Account, options?: SecretOptions, inherited?: boolean) : Promise { 33 | let secrets: Secret[] = []; 34 | if (options?.environment_name) { 35 | const get_environment_options: GetEnvironmentOptions = { environment_name: options.environment_name }; 36 | const environment = await EnvironmentUtils.getEnvironment(app.api, account, get_environment_options); 37 | secrets = (await app.api.get(`environments/${environment.id}/secrets/values`, { params: { inherited: true } })).data; 38 | } else if (options?.cluster_name) { 39 | const cluster = await ClusterUtils.getCluster(app.api, account, options?.cluster_name); 40 | secrets = (await app.api.get(`clusters/${cluster.id}/secrets/values`, { params: { inherited } })).data; 41 | } else { 42 | secrets = (await app.api.get(`accounts/${account.id}/secrets/values`)).data; 43 | } 44 | 45 | return secrets; 46 | } 47 | 48 | static async batchUpdateSecrets(app: AppService, secrets: Secret[], account: Account, options?: SecretOptions): Promise { 49 | if (options?.environment_name) { 50 | const get_environment_options: GetEnvironmentOptions = { environment_name: options.environment_name }; 51 | const environment = await EnvironmentUtils.getEnvironment(app.api, account, get_environment_options); 52 | await app.api.post(`/environments/${environment.id}/secrets/batch`, secrets); 53 | } else if (options?.cluster_name) { 54 | const cluster = await ClusterUtils.getCluster(app.api, account, options?.cluster_name); 55 | await app.api.post(`/clusters/${cluster.id}/secrets/batch`, secrets); 56 | } else { 57 | await app.api.post(`/accounts/${account.id}/secrets/batch`, secrets); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/integration/stateless-component/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | Architect Logo 6 | 7 |

8 | 9 |

10 | A dynamic microservices framework for building, connecting, and deploying cloud-native applications. 11 |

12 | 13 | --- 14 | 15 | # Stateless Architect components 16 | 17 | For apps and services that don't require their own databases or state, describing your service might seem easy ([click here to see how to describe ones that do](../stateful-component)). However, if you need to connect to peer API services you might find yourself annoyed at the complexity involved with networking, service discovery, network security, and more. Architects dependency resolver can help remediate that. 18 | 19 | In this example, you'll see the code for a simple Next.js web application that connects back to the hello world example component as a dependency. All we have to do is specify the name and version of this component in the `dependencies` block of our [architect.yml](./architect.yml) in order to automatically provision the dependency. Once referenced, we can use Architects embedded [service discovery](//docs.architect.io/components/dependencies/#dependency-referencing-syntax) features to connect to it automatigically. 20 | 21 | [Learn more about the architect.yml file](//docs.architect.io/configuration) 22 | 23 | ## Running locally 24 | 25 | Architect component specs are declarative, so it can be run locally or remotely with a single deploy command: 26 | 27 | ```sh 28 | # Clone the repository and navigate to this directory 29 | $ git clone https://github.com/architect-team/architect-cli.git 30 | $ cd ./architect-cli/examples/stateless-component 31 | 32 | # Add the dependency to the local registry 33 | $ architect link ../hello-world/architect.yml 34 | 35 | # Deploy using the dev command 36 | $ architect dev ./architect.yml 37 | ``` 38 | 39 | Once the deploy has completed, you can reach your new service by going to https://frontend.localhost.architect.sh/. 40 | 41 | ## Deploying to the cloud 42 | 43 | Want to try deploying this to a cloud environment? Architect's got you covered there too! if you've already [created your account](https://cloud.architect.io/signup), you can run the command below to deploy the component to a sample Kubernetes cluster powered by Architect Cloud: 44 | 45 | ```sh 46 | $ architect register ../hello-world/architect.yml 47 | 48 | $ architect deploy ./architect.yml -e example-environment 49 | ``` 50 | -------------------------------------------------------------------------------- /test/commands/environments/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test'; 2 | import sinon, { SinonSpy } from 'sinon'; 3 | import BaseTable from '../../../src/base-table'; 4 | import Environments from '../../../src/commands/environments/index'; 5 | import localizedTimestamp from '../../../src/common/utils/localized-timestamp'; 6 | import { MockArchitectApi } from '../../utils/mocks'; 7 | 8 | describe('environments', () => { 9 | const mock_account = { 10 | name: 'architect', 11 | }; 12 | 13 | const mock_environments = [ 14 | { 15 | name: 'example-environment', 16 | namespace: 'arc-architect--example-environment', 17 | account: { 18 | name: 'architect' 19 | }, 20 | created_at: (new Date()).toString(), 21 | updated_at: (new Date()).toString(), 22 | }, 23 | { 24 | name: 'example-environment', 25 | namespace: 'arc-architect-staging--example-environment', 26 | account: { 27 | name: 'architect-staging', 28 | }, 29 | created_at: (new Date()).toString(), 30 | updated_at: (new Date()).toString(), 31 | }, 32 | { 33 | name: 'production', 34 | namespace: 'arc-architect--production', 35 | account: { 36 | name: 'architect', 37 | }, 38 | created_at: (new Date()).toString(), 39 | updated_at: (new Date()).toString(), 40 | } 41 | ]; 42 | 43 | new MockArchitectApi() 44 | .getEnvironments() 45 | .getTests() 46 | .command(['environments']) 47 | .it('list environments', ctx => { 48 | expect(ctx.stdout).to.contain('You have not configured any environments'); 49 | }); 50 | 51 | new MockArchitectApi() 52 | .getEnvironments([], { query: mock_account.name }) 53 | .getTests() 54 | .command(['environments', mock_account.name]) 55 | .it('list environments for account if none exist', ctx => { 56 | expect(ctx.stdout).to.contain('No environments found matching architect'); 57 | }); 58 | 59 | new MockArchitectApi() 60 | .getEnvironments(mock_environments, { query: mock_account.name }) 61 | .getTests() 62 | .stub(Environments.prototype, 'log', sinon.fake.returns(null)) 63 | .command(['environments', mock_account.name]) 64 | .it('list environments for account', ctx => { 65 | const table = new BaseTable({ head: ['Name', 'Account', 'Namespace', 'Created', 'Updated'] }); 66 | for (const env of mock_environments) { 67 | table.push([ 68 | env.name, 69 | env.account.name, 70 | env.namespace, 71 | localizedTimestamp(env.created_at), 72 | localizedTimestamp(env.updated_at), 73 | ]); 74 | } 75 | const environment_list_spy = Environments.prototype.log as SinonSpy; 76 | expect(environment_list_spy.firstCall.args[0]).to.equal(table.toString()); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/commands/link/unlink.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import chalk from 'chalk'; 3 | import fs from 'fs-extra'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | import sinon, { SinonSpy } from 'sinon'; 7 | import AppConfig from '../../../src/app-config/config'; 8 | import AppService from '../../../src/app-config/service'; 9 | import Unlink from '../../../src/commands/unlink'; 10 | import ARCHITECTPATHS from '../../../src/paths'; 11 | 12 | 13 | describe('unlink', () => { 14 | let tmp_dir = os.tmpdir(); 15 | const component_path = path.join(__dirname, '../../mocks/superset/').replace(/\/$/g, '').replace(/\\$/gi, '').toLowerCase(); 16 | const bad_path = path.join(__dirname, '../examples').toLowerCase(); 17 | 18 | beforeEach(() => { 19 | // Stub the log_level 20 | const config = new AppConfig('', { 21 | log_level: 'info', 22 | }); 23 | const tmp_linked_components_file = path.join(tmp_dir, ARCHITECTPATHS.LINKED_COMPONENT_MAP_FILENAME); 24 | fs.writeJSONSync(tmp_linked_components_file, { 25 | 'examples/superset': component_path, 26 | }); 27 | const tmp_config_file = path.join(tmp_dir, ARCHITECTPATHS.CLI_CONFIG_FILENAME); 28 | fs.writeJSONSync(tmp_config_file, config); 29 | const app_config_stub = sinon.stub().returns(new AppService(tmp_dir, '0.0.1')); 30 | sinon.replace(AppService, 'create', app_config_stub); 31 | }); 32 | 33 | afterEach(() => { 34 | sinon.restore(); 35 | }); 36 | 37 | test 38 | .stub(Unlink.prototype, 'log', sinon.fake.returns(null)) 39 | .command(['unlink', bad_path]) 40 | .it('should fail link without component config', () => { 41 | const log_spy = Unlink.prototype.log as SinonSpy; 42 | expect(log_spy.calledOnce).to.equal(true); 43 | expect(log_spy.firstCall.args[0]).to.equal(chalk.red(`No linked component found matching, ${bad_path}`)); 44 | 45 | const linked_components_file = path.join(tmp_dir, ARCHITECTPATHS.LINKED_COMPONENT_MAP_FILENAME); 46 | const linked_components = fs.readJSONSync(linked_components_file); 47 | expect(linked_components).to.have.property('examples/superset', component_path); 48 | }); 49 | 50 | test 51 | .stub(Unlink.prototype, 'log', sinon.fake.returns(null)) 52 | .command(['unlink', component_path]) 53 | .it('should unlink component', () => { 54 | const log_spy = Unlink.prototype.log as SinonSpy; 55 | expect(log_spy.calledOnce).to.equal(true); 56 | expect(log_spy.firstCall.args[0]).to.equal(chalk.green('Successfully unlinked examples/superset')); 57 | 58 | const linked_components_file = path.join(tmp_dir, ARCHITECTPATHS.LINKED_COMPONENT_MAP_FILENAME); 59 | const linked_components = fs.readJSONSync(linked_components_file); 60 | expect(linked_components).not.to.have.property('examples/superset'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/common/plugins/plugin-manager.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import path from 'path'; 3 | import { Dictionary } from '../..'; 4 | import { ArchitectPlugin, PluginArchitecture, PluginBundleType, PluginPlatform } from './plugin-types'; 5 | import PluginUtils from './plugin-utils'; 6 | 7 | export default class PluginManager { 8 | private static readonly plugins: Dictionary = {}; 9 | 10 | private static readonly ARCHITECTURE_MAP: Dictionary = { 11 | 'x64': PluginArchitecture.AMD64, 12 | 'arm64': PluginArchitecture.ARM64, 13 | }; 14 | 15 | private static readonly OPERATIN_SYSTEM_MAP: Dictionary = { 16 | 'win32': PluginPlatform.WINDOWS, 17 | 'darwin': PluginPlatform.DARWIN, 18 | 'linux': PluginPlatform.LINUX, 19 | }; 20 | 21 | private static getPlatform(): PluginPlatform { 22 | return this.OPERATIN_SYSTEM_MAP[process.platform]; 23 | } 24 | 25 | private static getArchitecture(): PluginArchitecture { 26 | return this.ARCHITECTURE_MAP[process.arch]; 27 | } 28 | 29 | private static async removeOldPluginVersions(pluginDirectory: string, version: string) { 30 | if (!(await fs.pathExists(pluginDirectory))) { 31 | return; 32 | } 33 | const downloaded_versions = await fs.readdir(pluginDirectory); 34 | for (const downloaded_version of downloaded_versions) { 35 | if (downloaded_version === version) { 36 | continue; 37 | } 38 | await fs.remove(path.join(pluginDirectory, downloaded_version)); 39 | } 40 | } 41 | 42 | static async getPlugin(pluginDirectory: string, ctor: { new(): T; }): Promise { 43 | if (this.plugins[ctor.name]) { 44 | return this.plugins[ctor.name] as T; 45 | } 46 | const plugin = new ctor(); 47 | const current_plugin_directory = path.join(pluginDirectory, `/${plugin.name}`); 48 | const version_path = path.join(current_plugin_directory, `/${plugin.version}`); 49 | 50 | await this.removeOldPluginVersions(current_plugin_directory, plugin.version); 51 | await fs.mkdirp(version_path); 52 | 53 | const binary = PluginUtils.getBinary(plugin.binaries, this.getPlatform(), this.getArchitecture()); 54 | const downloaded_file_path = path.join(version_path, `/${plugin.name}.${binary.bundle_type === PluginBundleType.ZIP ? 'zip' : 'tar.gz'}`); 55 | 56 | if (!(await fs.pathExists(path.join(version_path, `/${binary.executable_path}`)))) { 57 | await PluginUtils.downloadFile(binary.url, downloaded_file_path, binary.sha256); 58 | await PluginUtils.extractFile(downloaded_file_path, version_path, binary.bundle_type); 59 | await fs.remove(downloaded_file_path); 60 | } 61 | 62 | await plugin.setup(version_path, PluginUtils.getBinary(plugin.binaries, this.getPlatform(), this.getArchitecture())); 63 | 64 | this.plugins[ctor.name] = plugin; 65 | return plugin as T; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/commands/port-forward.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@oclif/test'; 2 | import * as net from 'net'; 3 | import { Replica } from '../../src/architect/environment/environment.utils'; 4 | import PortForward from '../../src/commands/port-forward'; 5 | import { MockArchitectApi } from '../utils/mocks'; 6 | 7 | describe('port-forward command', () => { 8 | const account = { 9 | name: 'examples', 10 | id: '1', 11 | }; 12 | 13 | const environment = { 14 | name: 'test', 15 | id: '1', 16 | }; 17 | 18 | const component = { 19 | name: 'react-app', 20 | }; 21 | 22 | const service = { 23 | name: 'app', 24 | }; 25 | 26 | const replicas: Replica[] = [ 27 | { ext_ref: 'ext-0', node_ref: 'node-ref-0', resource_ref: `${component.name}.services.${service.name}`, created_at: new Date().toUTCString(), ports: [8080] }, 28 | ]; 29 | 30 | const multiple_replicas: Replica[] = [ 31 | { ext_ref: 'ext-0', node_ref: 'node-ref-0', resource_ref: `${component.name}.services.${service.name}`, created_at: new Date().toUTCString(), ports: [8080] }, 32 | { ext_ref: 'ext-1', node_ref: 'node-ref-0', resource_ref: `${component.name}.services.${service.name}`, created_at: new Date().toUTCString(), ports: [8080] }, 33 | ]; 34 | 35 | new MockArchitectApi() 36 | .getAccount(account) 37 | .getEnvironment(account, environment) 38 | .getEnvironmentReplicas(environment, replicas) 39 | .getTests() 40 | .stub(net.Server.prototype, 'listen', () => {}) 41 | .stub(PortForward.prototype, 'portForward', () => { console.log('worked'); }) 42 | .stdout() 43 | .command(['port-forward', '-a', account.name, '-e', environment.name]) 44 | .it('port-forward command', ctx => { 45 | expect(ctx.stdout).to.include('Forwarding'); 46 | }); 47 | 48 | new MockArchitectApi() 49 | .getAccount(account) 50 | .getEnvironment(account, environment) 51 | .getEnvironmentReplicas(environment, replicas) 52 | .getTests() 53 | .stub(net.Server.prototype, 'listen', () => {}) 54 | .stub(PortForward.prototype, 'portForward', () => { console.log('worked'); }) 55 | .stdout() 56 | .command(['port-forward', '-a', account.name, '-e', environment.name, '-r', '0']) 57 | .it('port-forward component and replica of the form when there is only one service', ctx => { 58 | expect(ctx.stdout).to.include('Forwarding'); 59 | }); 60 | 61 | new MockArchitectApi() 62 | .getAccount(account) 63 | .getEnvironment(account, environment) 64 | .getEnvironmentReplicas(environment, multiple_replicas, service) 65 | .getTests() 66 | .stdout() 67 | .command(['port-forward', '-a', account.name, '-e', environment.name, 'app', '-r', '2']) 68 | .catch(err => { 69 | expect(`${err}`).to.contain('Replica not found at index 2'); 70 | }) 71 | .it('port-forward component and replica failed indexing out-of-bound replica index'); 72 | }); 73 | -------------------------------------------------------------------------------- /src/commands/dev/list.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import { Dictionary } from '../..'; 3 | import BaseCommand from '../../base-command'; 4 | import BaseTable from '../../base-table'; 5 | import { DockerComposeUtils } from '../../common/docker-compose'; 6 | import { DockerInspect } from '../../common/docker-compose/template'; 7 | import { RequiresDocker } from '../../common/docker/helper'; 8 | 9 | export default class DevList extends BaseCommand { 10 | async auth_required(): Promise { 11 | return false; 12 | } 13 | 14 | static flags = { 15 | format: Flags.string({ 16 | char: 'f', 17 | description: `Format to output data in. Table or JSON`, 18 | default: 'table', 19 | options: ['TABLE', 'table', 'JSON', 'json'], 20 | }), 21 | }; 22 | 23 | static description = 'List all running dev instances.'; 24 | static examples = ['architect dev:list']; 25 | 26 | outputTable(local_env_map: Dictionary): void { 27 | const table = new BaseTable({ head: ['Environment', 'Containers', 'Status'] }); 28 | 29 | for (const [env_name, containers] of Object.entries(local_env_map)) { 30 | const container_names = this.getContainerNames(containers).join('\n'); 31 | const statuses = this.getContainerStates(containers).join('\n'); 32 | table.push([env_name, container_names, statuses]); 33 | } 34 | if (table.length === 0) { 35 | this.log('There are no active dev instances yet. Use `architect dev` to create one.'); 36 | } else { 37 | this.log(table.toString()); 38 | } 39 | } 40 | 41 | getContainerStates(containers: DockerInspect[]): string[] { 42 | return containers.map(c => c.State.Status); 43 | } 44 | 45 | getContainerNames(containers: DockerInspect[]): string[] { 46 | // "Name" has a preceding '/' that we stripe 47 | return containers.map(c => c.Name.substring(1)); 48 | } 49 | 50 | outputJSON(local_env_map: Dictionary): void { 51 | const output: Dictionary = {}; 52 | for (const [env_name, containers] of Object.entries(local_env_map)) { 53 | output[env_name] = {} as Dictionary; 54 | const container_names = this.getContainerNames(containers); 55 | const statuses = this.getContainerStates(containers); 56 | for (const [i, container_name] of container_names.entries()) { 57 | output[env_name][container_name] = { 58 | status: statuses[i], 59 | }; 60 | } 61 | } 62 | this.log(JSON.stringify(output, null, 2)); 63 | } 64 | 65 | @RequiresDocker({ compose: true }) 66 | async run(): Promise { 67 | const { args, flags } = await this.parse(DevList); 68 | 69 | const local_env_map = await DockerComposeUtils.getLocalEnvironmentContainerMap(); 70 | if (flags.format.toLowerCase() === 'table') { 71 | this.outputTable(local_env_map); 72 | } else { 73 | this.outputJSON(local_env_map); 74 | } 75 | } 76 | } 77 | --------------------------------------------------------------------------------