├── docs ├── .gitkeep └── images │ ├── application.png │ └── deployments.png ├── webui ├── src │ ├── assets │ │ ├── .gitkeep │ │ ├── not-found.html │ │ └── i18n │ │ │ └── en.json │ ├── app │ │ ├── widget │ │ │ ├── graph │ │ │ │ ├── graph.component.css │ │ │ │ └── graph.component.html │ │ │ ├── help-widget │ │ │ │ ├── help-widget.component.css │ │ │ │ ├── help-widget.component.html │ │ │ │ └── help-widget.component.ts │ │ │ ├── cytograph │ │ │ │ ├── cytograph.component.html │ │ │ │ └── cytograph.component.css │ │ │ └── badgewidget │ │ │ │ ├── badgewidget.component.html │ │ │ │ ├── badgewidget.component.css │ │ │ │ └── badgewidget.component.ts │ │ ├── components │ │ │ ├── env-chip │ │ │ │ ├── env-chip.component.css │ │ │ │ ├── blank.gif │ │ │ │ ├── env-chip.component.html │ │ │ │ ├── env-chip.component.spec.ts │ │ │ │ └── env-chip.component.ts │ │ │ ├── openapi-ui │ │ │ │ ├── openapi-ui.component.html │ │ │ │ └── openapi-ui.component.ts │ │ │ ├── domains-browse │ │ │ │ ├── domains-browse.component.css │ │ │ │ ├── domains-browse.component.html │ │ │ │ └── domains-browse.component.ts │ │ │ ├── graphs │ │ │ │ ├── graph-browse.component.html │ │ │ │ └── graph-browse.component.ts │ │ │ ├── badges │ │ │ │ ├── badges.component.css │ │ │ │ └── badges.component.html │ │ │ ├── applications │ │ │ │ ├── applications.component.css │ │ │ │ ├── applications.component.spec.ts │ │ │ │ └── applications.component.html │ │ │ └── appdetail │ │ │ │ ├── appdetail.component.spec.ts │ │ │ │ └── appdetail.component.css │ │ ├── kit │ │ │ ├── oui-pagination │ │ │ │ ├── oui-pagination.component.css │ │ │ │ ├── oui-pagination.component.html │ │ │ │ └── oui-pagination.component.ts │ │ │ ├── oui-progress-tracker │ │ │ │ ├── oui-progress-tracker.component.css │ │ │ │ ├── oui-progress-tracker.component.html │ │ │ │ └── oui-progress-tracker.component.ts │ │ │ ├── oui-action-menu │ │ │ │ ├── oui-action-menu.component.css │ │ │ │ ├── oui-action-menu.component.html │ │ │ │ └── oui-action-menu.component.ts │ │ │ ├── oui-nav-bar │ │ │ │ ├── oui-nav-bar.component.css │ │ │ │ ├── oui-nav-bar.component.html │ │ │ │ └── oui-nav-bar.component.ts │ │ │ └── oui-message │ │ │ │ ├── oui-message.component.html │ │ │ │ └── oui-message.component.ts │ │ ├── app.component.css │ │ ├── models │ │ │ ├── commons │ │ │ │ ├── content-bean.ts │ │ │ │ ├── entity-bean.ts │ │ │ │ ├── badges-bean.ts │ │ │ │ └── applications-bean.ts │ │ │ ├── kit │ │ │ │ ├── paginate.ts │ │ │ │ ├── progress-tracker.ts │ │ │ │ └── navbar.ts │ │ │ └── graph │ │ │ │ └── graph-bean.ts │ │ ├── stores │ │ │ ├── action-with-payload.ts │ │ │ ├── environments-store.service.ts │ │ │ ├── graphs-store.service.ts │ │ │ ├── badges-store.service.ts │ │ │ ├── errors-store.service.ts │ │ │ ├── help-store.service.ts │ │ │ └── config-store.service.ts │ │ ├── guards │ │ │ ├── profile.guard.ts │ │ │ └── routing.guard.ts │ │ ├── services │ │ │ ├── configuration.service.ts │ │ │ ├── data-badge.service.ts │ │ │ ├── data-graph.service.ts │ │ │ ├── data-domain.service.ts │ │ │ ├── data-deployment.service.ts │ │ │ ├── data-environment.service.ts │ │ │ ├── data-application-version.service.ts │ │ │ ├── data-content.service.ts │ │ │ ├── security.service.ts │ │ │ ├── data-badgestats.service.ts │ │ │ ├── data-badgeratings.service.ts │ │ │ ├── data-stream-resources.service.ts │ │ │ └── data-graph-resources.service.ts │ │ ├── shared │ │ │ └── decorator │ │ │ │ └── autoUnsubscribe.ts │ │ ├── interfaces │ │ │ └── default-resources.interface.ts │ │ ├── app.component.html │ │ ├── pipes │ │ │ └── pipes-applications.component.ts │ │ ├── app.component.spec.ts │ │ ├── resolver │ │ │ ├── resolve-graph.ts │ │ │ ├── resolve-domains.ts │ │ │ ├── resolve-applications.ts │ │ │ └── resolve-badges.ts │ │ └── app-routing.module.ts │ ├── styles.css │ ├── environments │ │ ├── environment.ts │ │ └── environment.prod.ts │ ├── typings.d.ts │ ├── tsconfig.app.json │ ├── main.ts │ ├── tsconfig.spec.json │ ├── index.html │ ├── test.ts │ └── polyfills.ts ├── e2e │ ├── app.po.ts │ ├── tsconfig.e2e.json │ └── app.e2e-spec.ts ├── .editorconfig ├── tsconfig.json ├── proxy-conf.js ├── .gitignore ├── protractor.conf.js ├── karma.conf.js ├── Makefile ├── README.md ├── package.json └── tslint.json ├── api ├── .gitignore ├── handlers │ ├── version.go │ ├── ping.go │ ├── handlers_test.go │ └── hooks.go ├── config │ ├── type.go │ └── loader.go ├── hateoas │ ├── hooks.go │ ├── errors.go │ └── utils.go ├── .gometalinter.json ├── logger │ └── logger.go ├── db │ ├── type.go │ ├── migrations.go │ ├── transactions.go │ ├── logger.go │ └── dbconfig.go ├── v1 │ ├── graph │ │ └── handlers.go │ ├── environment │ │ ├── handlers.go │ │ └── environment_test.go │ ├── deployment │ │ ├── depend.go │ │ └── deployer.go │ ├── badge │ │ ├── handlers.go │ │ └── badge_test.go │ ├── content │ │ └── handlers.go │ ├── application │ │ └── latest.go │ └── domain │ │ └── repository.go ├── graphapi │ ├── graph.go │ └── type.go ├── tests │ └── fixtures.go ├── security │ ├── type.go │ ├── policy.go │ └── policy_test.go ├── Gopkg.toml └── Makefile ├── migrations ├── v0009.sql ├── v0008.sql ├── v0010.sql ├── v0003.sql ├── v0004.sql ├── v0006.sql ├── v0005.sql ├── v0002.sql ├── v0001.sql └── v0007.sql ├── .gitignore ├── docker-compose.yml ├── tests ├── 01-version.yml ├── 02-ping.yml ├── 11-applications-latest-v1.yml ├── 40-contents-v1.yml └── 20-environments-v1.yml ├── CHANGELOG.md ├── docker └── main │ └── Dockerfile ├── .config.json.dist ├── scripts └── appcatalog.sh ├── ROADMAP.md ├── LICENSE ├── Makefile └── README.md /docs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webui/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webui/src/app/widget/graph/graph.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webui/src/assets/not-found.html: -------------------------------------------------------------------------------- 1 | Not found 2 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | **/*.png 3 | *-packr.go 4 | -------------------------------------------------------------------------------- /webui/src/app/components/env-chip/env-chip.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webui/src/styles.css: -------------------------------------------------------------------------------- 1 | @import '~swagger-ui/dist/swagger-ui.css' 2 | -------------------------------------------------------------------------------- /webui/src/app/components/openapi-ui/openapi-ui.component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /docs/images/application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ovh/lhasa/HEAD/docs/images/application.png -------------------------------------------------------------------------------- /docs/images/deployments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ovh/lhasa/HEAD/docs/images/deployments.png -------------------------------------------------------------------------------- /webui/src/app/components/domains-browse/domains-browse.component.css: -------------------------------------------------------------------------------- 1 | .count{ 2 | font-size: small; 3 | } -------------------------------------------------------------------------------- /webui/src/app/kit/oui-pagination/oui-pagination.component.css: -------------------------------------------------------------------------------- 1 | .oui-pagination { 2 | clear: both; 3 | } 4 | -------------------------------------------------------------------------------- /webui/src/app/widget/help-widget/help-widget.component.css: -------------------------------------------------------------------------------- 1 | .can-be-clicked { 2 | cursor: pointer; 3 | } 4 | -------------------------------------------------------------------------------- /migrations/v0009.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | UPDATE releases SET badge_ratings = NULL; 4 | 5 | -- +migrate Up 6 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-progress-tracker/oui-progress-tracker.component.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | -------------------------------------------------------------------------------- /webui/src/app/components/env-chip/blank.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ovh/lhasa/HEAD/webui/src/app/components/env-chip/blank.gif -------------------------------------------------------------------------------- /webui/src/app/kit/oui-action-menu/oui-action-menu.component.css: -------------------------------------------------------------------------------- 1 | 2 | .adjust-width { 3 | width: 100%; 4 | min-width: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /webui/src/app/widget/cytograph/cytograph.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /webui/src/app/components/graphs/graph-browse.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /webui/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | tracing: true, 4 | apiUrl: '/api/' 5 | }; 6 | -------------------------------------------------------------------------------- /webui/src/app/widget/cytograph/cytograph.component.css: -------------------------------------------------------------------------------- 1 | ngx-cytoscape { 2 | position: absolute; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /webui/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | tracing: false, 4 | apiUrl: '/api/' 5 | }; 6 | -------------------------------------------------------------------------------- /webui/src/app/app.component.css: -------------------------------------------------------------------------------- 1 | .block-widget-overlay { 2 | background-color: #ffffff; 3 | opacity: 0.9; 4 | filter: alpha(opacity=90); 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | test.db 3 | **/*.png 4 | vault-file.json 5 | .config.json 6 | **/gin-bin 7 | **/test_results.xml 8 | .cds 9 | .sherpa.json 10 | debug 11 | -------------------------------------------------------------------------------- /webui/src/app/models/commons/content-bean.ts: -------------------------------------------------------------------------------- 1 | import { EntityBean } from './entity-bean'; 2 | 3 | // Content 4 | export class ContentBean extends String { 5 | } 6 | -------------------------------------------------------------------------------- /webui/src/app/components/badges/badges.component.css: -------------------------------------------------------------------------------- 1 | 2 | dl{ 3 | margin: 0.5em; 4 | } 5 | dl dt { 6 | float:left; 7 | font-weight:bold; 8 | width:7em; 9 | } 10 | -------------------------------------------------------------------------------- /webui/src/app/models/kit/paginate.ts: -------------------------------------------------------------------------------- 1 | 2 | export class UiKitPaginate { 3 | totalElements: number; 4 | totalPages: number; 5 | size: number; 6 | number: number; 7 | } 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | db: 4 | image: postgres:9.4 5 | environment: 6 | POSTGRES_PASSWORD: appcatalog 7 | ports: 8 | - ${PORT}32:5432 9 | -------------------------------------------------------------------------------- /migrations/v0008.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | ALTER TABLE releases DROP COLUMN IF EXISTS "properties"; 4 | 5 | -- +migrate Up 6 | 7 | ALTER TABLE releases ADD COLUMN "properties" JSONB; 8 | -------------------------------------------------------------------------------- /webui/src/app/widget/help-widget/help-widget.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /webui/src/app/models/kit/progress-tracker.ts: -------------------------------------------------------------------------------- 1 | 2 | export class UiKitStep { 3 | id: string; 4 | label: string; 5 | status: string; 6 | stepClass?: string; 7 | labelClass?: string; 8 | } 9 | -------------------------------------------------------------------------------- /webui/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare var module: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | declare var global: any; 7 | declare var require: any; 8 | -------------------------------------------------------------------------------- /migrations/v0010.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | DROP INDEX IF EXISTS "idx_deployments_public_id"; 4 | 5 | -- +migrate Up 6 | 7 | CREATE UNIQUE INDEX "idx_deployments_public_id" 8 | ON "deployments" ("public_id"); 9 | -------------------------------------------------------------------------------- /webui/src/app/models/kit/navbar.ts: -------------------------------------------------------------------------------- 1 | 2 | export class UiKitMenuItem { 3 | id: string; 4 | label: string; 5 | routerLink?: string; 6 | icon?: string; 7 | expanded?: boolean; 8 | items?: UiKitMenuItem[]; 9 | } 10 | -------------------------------------------------------------------------------- /webui/e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /webui/src/app/widget/badgewidget/badgewidget.component.html: -------------------------------------------------------------------------------- 1 | 2 | {{_badge.title}}{{_badge.label}} 3 | 4 | -------------------------------------------------------------------------------- /tests/01-version.yml: -------------------------------------------------------------------------------- 1 | name: Monitoring TestSuite 2 | testcases: 3 | - name: GET {{.APP_HOST}}/unsecured/version 4 | steps: 5 | - type: http 6 | method: GET 7 | url: "{{.APP_HOST}}/api/unsecured/version" 8 | assertions: 9 | - result.statuscode ShouldEqual 200 10 | -------------------------------------------------------------------------------- /webui/src/app/widget/graph/graph.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |
6 |
7 | -------------------------------------------------------------------------------- /webui/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "baseUrl": "./", 6 | "module": "es2015", 7 | "types": [] 8 | }, 9 | "exclude": [ 10 | "test.ts", 11 | "**/*.spec.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /migrations/v0003.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | ALTER TABLE "deployments" DROP COLUMN IF EXISTS "dependencies"; 4 | 5 | -- +migrate Up 6 | 7 | ALTER TABLE "deployments" ADD "dependencies" JSONB; 8 | 9 | CREATE INDEX idx_deployments_dependencies ON "deployments" USING GIN ("dependencies"); 10 | 11 | -------------------------------------------------------------------------------- /webui/src/app/components/applications/applications.component.css: -------------------------------------------------------------------------------- 1 | .application-description { 2 | height: 5em; 3 | overflow: hidden; 4 | } 5 | 6 | .pad-tile { 7 | padding: 8px; 8 | cursor: pointer; 9 | } 10 | 11 | .filter{ 12 | float: right; 13 | } 14 | 15 | .count{ 16 | font-size: small; 17 | } -------------------------------------------------------------------------------- /webui/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /webui/e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "jasminewd2", 11 | "node" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /webui/src/app/components/env-chip/env-chip.component.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | {{ environments[slug].name }} 5 | 6 | -------------------------------------------------------------------------------- /tests/02-ping.yml: -------------------------------------------------------------------------------- 1 | name: Monitoring TestSuite 2 | testcases: 3 | - name: GET {{.APP_HOST}}/unsecured/ping 4 | steps: 5 | - type: http 6 | method: GET 7 | url: "{{.APP_HOST}}/api/unsecured/mon" 8 | assertions: 9 | - result.statuscode ShouldEqual 200 10 | - result.body ShouldEqual '"OK"' 11 | -------------------------------------------------------------------------------- /api/handlers/version.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | // VersionHandler displays the current version number 8 | func VersionHandler(version string) func(*gin.Context) (string, error) { 9 | return func(_ *gin.Context) (string, error) { 10 | return version, nil 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /migrations/v0004.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | DROP INDEX IF EXISTS idx_deployments_application_id; 4 | DROP INDEX IF EXISTS idx_deployments_environment_id; 5 | 6 | -- +migrate Up 7 | 8 | CREATE INDEX idx_deployments_application_id ON deployments (application_id); 9 | CREATE INDEX idx_deployments_environment_id ON deployments (environment_id); 10 | 11 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-nav-bar/oui-nav-bar.component.css: -------------------------------------------------------------------------------- 1 | .oui-navbar-link { 2 | min-height: 1.0rem; 3 | } 4 | 5 | .oui-navbar-menu .oui-navbar-link { 6 | width: auto; 7 | } 8 | 9 | .navbar-container { 10 | position: fixed; 11 | width: 100%; 12 | top: 0; 13 | z-index: 2; 14 | } 15 | 16 | .navbar-spacer { 17 | height: 5em; 18 | } 19 | -------------------------------------------------------------------------------- /webui/e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('console App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to app!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /api/config/type.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/ovh/lhasa/api/db" 5 | "github.com/ovh/lhasa/api/security" 6 | ) 7 | 8 | // Lhasa is the main config format 9 | type Lhasa struct { 10 | DB db.DatabaseCredentials `json:"appcatalog-db"` 11 | Policy security.Policy `json:"security"` 12 | LogHeaders []string `json:"log-headers"` 13 | } 14 | -------------------------------------------------------------------------------- /api/handlers/ping.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // PingHandler provides monitoring status on a rest endpoint 10 | func PingHandler(db *sql.DB) func(c *gin.Context) (string, error) { 11 | return func(c *gin.Context) (string, error) { 12 | err := db.Ping() 13 | if err != nil { 14 | return "", err 15 | } 16 | return "OK", nil 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /webui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /webui/src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "node" 11 | ] 12 | }, 13 | "files": [ 14 | "test.ts", 15 | "polyfills.ts" 16 | ], 17 | "include": [ 18 | "**/*.spec.ts", 19 | "**/*.d.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /webui/src/app/stores/action-with-payload.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | import { Subject } from 'rxjs'; 3 | 4 | export class ActionWithPayload implements Action { 5 | readonly type: string; 6 | constructor(public payload: T) { 7 | } 8 | } 9 | 10 | export class ActionWithPayloadAndPromise implements Action { 11 | readonly type: string; 12 | constructor(public payload: T, public subject?: Subject) { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /webui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "target": "es5", 11 | "typeRoots": [ 12 | "node_modules/@types" 13 | ], 14 | "lib": [ 15 | "es2017", 16 | "dom" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webui/src/app/guards/profile.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable() 6 | export class ProfileGuard implements CanActivate { 7 | canActivate( 8 | next: ActivatedRouteSnapshot, 9 | state: RouterStateSnapshot): Observable | Promise | boolean { 10 | return true; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-message/oui-message.component.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | 11 | ## [0.1.1] - 2018-02-19 12 | ### Added 13 | - First release 14 | - Basic application CRUD in the API 15 | - Basic angular Web UI 16 | 17 | [Unreleased]: https://github.com/ovh/lhasa/commits/master 18 | -------------------------------------------------------------------------------- /webui/proxy-conf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * gateway deployment simulation 3 | * @param {*} req 4 | * @param {*} res 5 | * @param {*} proxyOptions 6 | */ 7 | 8 | const injectCreds = function (req, res, proxyOptions) { 9 | console.log("Inject."); 10 | req.headers["X-Remote-User"] = "fabien.meurillon"; 11 | }; 12 | 13 | const PROXY_CONFIG = { 14 | "/api": { 15 | "target": "http://localhost:8081", 16 | "secure": false 17 | }, 18 | "/all": { 19 | "secure": false 20 | }, 21 | } 22 | 23 | module.exports = PROXY_CONFIG; 24 | -------------------------------------------------------------------------------- /api/hateoas/hooks.go: -------------------------------------------------------------------------------- 1 | package hateoas 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/loopfz/gadgeto/tonic" 6 | ) 7 | 8 | // RenderHookWrapper handles hateoas entity conversion 9 | func RenderHookWrapper(hook tonic.RenderHook) tonic.RenderHook { 10 | return func(c *gin.Context, status int, payload interface{}) { 11 | baseURL := BaseURL(c) 12 | switch r := payload.(type) { 13 | case Resourceable: 14 | r.ToResource(baseURL) 15 | } 16 | if hook == nil { 17 | return 18 | } 19 | hook(c, status, payload) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker/main/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie 2 | 3 | COPY dist /app 4 | 5 | WORKDIR /app 6 | 7 | RUN chown -R nobody:nogroup /app \ 8 | && chmod +x /app/mycompany.sh \ 9 | /app/appcatalog.sh \ 10 | /app/appcatalog-configuration \ 11 | /app/appcatalog \ 12 | && apt-get update \ 13 | && apt-get install -y curl wget ca-certificates 14 | 15 | USER nobody 16 | 17 | EXPOSE 8081 18 | 19 | CMD ./appcatalog-configuration --debug --output=./config.json start && ./appcatalog.sh --config=./config.json 20 | -------------------------------------------------------------------------------- /api/.gometalinter.json: -------------------------------------------------------------------------------- 1 | { 2 | "Enable": [ 3 | "vet", 4 | "gotype", 5 | "gotypex", 6 | "deadcode", 7 | "gocyclo", 8 | "golint", 9 | "varcheck", 10 | "structcheck", 11 | "maligned", 12 | "errcheck", 13 | "megacheck", 14 | "ineffassign", 15 | "interfacer", 16 | "unconvert", 17 | "goconst", 18 | "gofmt", 19 | "goimports", 20 | "misspell" 21 | ], 22 | "Exclude": [ 23 | ".+-packr.go", 24 | ".*_test.go" 25 | ], 26 | "Vendor": true, 27 | "Test": true, 28 | "Deadline": "5m" 29 | } 30 | -------------------------------------------------------------------------------- /webui/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Application Catalog 6 | 7 | 8 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-action-menu/oui-action-menu.component.html: -------------------------------------------------------------------------------- 1 |
2 | 4 | 9 |
10 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-progress-tracker/oui-progress-tracker.component.html: -------------------------------------------------------------------------------- 1 |
2 |
    3 |
  • 4 | 5 |
  • 6 |
7 |
    8 |
  • {{ step.label | translate }}
  • 9 |
10 |
11 | -------------------------------------------------------------------------------- /webui/src/app/guards/routing.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, ActivatedRoute, Router, Params } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable() 6 | export class RoutingGuard implements CanActivate { 7 | 8 | constructor( 9 | private route: ActivatedRoute, 10 | private router: Router 11 | ) { 12 | 13 | } 14 | 15 | canActivate( 16 | next: ActivatedRouteSnapshot, 17 | state: RouterStateSnapshot): Observable | Promise | boolean { 18 | return true; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/config/loader.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/ovh/lhasa/api/security" 9 | ) 10 | 11 | // Empty returns an empty configuration struct 12 | func Empty() Lhasa { 13 | return Lhasa{ 14 | Policy: make(security.Policy), 15 | } 16 | } 17 | 18 | // LoadFromFile extract configuration file 19 | func LoadFromFile(configFile *os.File) (config Lhasa, err error) { 20 | config = Empty() 21 | // Init config file 22 | b, err := ioutil.ReadFile(configFile.Name()) 23 | if err != nil { 24 | return config, err 25 | } 26 | err = json.Unmarshal(b, &config) 27 | return config, err 28 | } 29 | -------------------------------------------------------------------------------- /.config.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "appcatalog-db": { 3 | "writers": [ 4 | { 5 | "host": "#addr#", 6 | "port": 5432, 7 | "sslmode": "disable" 8 | } 9 | ], 10 | "database": "postgres", 11 | "user": "postgres", 12 | "password": "#sample#", 13 | "type": "postgresql" 14 | }, 15 | "security": { 16 | "ROLE_ADMIN": { 17 | "X-Remote-User": ["john.doe"] 18 | }, 19 | "ROLE_USER": { 20 | "X-Remote-User": ["*"] 21 | } 22 | } 23 | "log-headers": [ 24 | "X-Remote-User", 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /webui/src/app/components/domains-browse/domains-browse.component.html: -------------------------------------------------------------------------------- 1 | 2 |

{{ 'DOMAINS' | translate }} ({{metadata.totalElements}})

3 |
4 | 5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /api/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/fabienm/go-logrus-formatters" 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // NewLogger creates a configured logrus logger instance 11 | func NewLogger(isVerbose, isDebug, isQuiet, isJSON bool) logrus.FieldLogger { 12 | log := logrus.New() 13 | log.Level = logrus.WarnLevel 14 | if isVerbose { 15 | log.Level = logrus.InfoLevel 16 | } 17 | if isQuiet { 18 | log.Level = logrus.FatalLevel 19 | } 20 | if isDebug { 21 | log.Level = logrus.DebugLevel 22 | } 23 | if isJSON { 24 | hostname, _ := os.Hostname() 25 | log.Formatter = formatters.NewGelf(hostname) 26 | } 27 | 28 | return log 29 | } 30 | -------------------------------------------------------------------------------- /webui/src/app/widget/badgewidget/badgewidget.component.css: -------------------------------------------------------------------------------- 1 | span.badge { 2 | color: white; 3 | font-size: smaller; 4 | padding: 0.2em; 5 | padding-left: 0.5em; 6 | padding-right: 0.5em; 7 | text-shadow: 0.1em 0.1em #01010145; 8 | white-space:nowrap; 9 | line-height: 2em; 10 | } 11 | 12 | span.left { 13 | border-radius: 0.3em 0em 0em 0.3em; 14 | background: #555; 15 | background: linear-gradient(to bottom, rgba(187, 187, 187, 0.2), rgba(0, 0, 0, 0.2)), #555; 16 | } 17 | 18 | span.right { 19 | border-radius: 0em 0.3em 0.3em 0em; 20 | background: red; 21 | background: linear-gradient(to bottom, rgba(187, 187, 187, 0.2), rgba(0, 0, 0, 0.2)), red; 22 | } 23 | -------------------------------------------------------------------------------- /webui/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /dist-server 6 | /tmp 7 | /out-tsc 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | testem.log 35 | /typings 36 | 37 | # e2e 38 | /e2e/*.js 39 | /e2e/*.map 40 | 41 | # System Files 42 | .DS_Store 43 | Thumbs.db 44 | -------------------------------------------------------------------------------- /webui/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /webui/src/app/models/commons/entity-bean.ts: -------------------------------------------------------------------------------- 1 | export class EntityBean { 2 | id?: string; 3 | timestamp?: Date; 4 | } 5 | 6 | export class HrefLinks { 7 | rel: string; 8 | href: string; 9 | } 10 | 11 | // Page meta data 12 | export class PageMetaData { 13 | totalElements?: number; 14 | totalPages?: number; 15 | size: number; 16 | number: number; 17 | } 18 | 19 | export class ContentListResponse { 20 | content: T[]; 21 | pageMetadata: PageMetaData; 22 | _links: {}; 23 | } 24 | 25 | 26 | export abstract class AbstractPaginatedResource { 27 | metadata: PageMetaData = { 28 | totalElements: 0, 29 | totalPages: 0, 30 | size: 0, 31 | number: 0 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /webui/src/app/services/configuration.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { environment } from '../../environments/environment'; 3 | 4 | @Injectable() 5 | export class ConfigurationService { 6 | 7 | public ApiUrl: string; 8 | public AuthToken: string; 9 | 10 | /** 11 | * constructor 12 | */ 13 | constructor() { 14 | this.ApiUrl = environment.apiUrl; 15 | } 16 | 17 | /** 18 | * fix session token 19 | * @param AuthToken 20 | */ 21 | public setAuthToken(AuthToken: string): void { 22 | this.AuthToken = AuthToken; 23 | } 24 | 25 | /** 26 | * get token 27 | * @param AuthToken 28 | */ 29 | public getAuthToken(): string { 30 | return this.AuthToken; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /migrations/v0006.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | -- Remove tables 4 | DROP TABLE IF EXISTS "badges"; 5 | 6 | ALTER TABLE applications DROP COLUMN IF EXISTS "badge_ratings"; 7 | 8 | -- Remove indexes 9 | 10 | 11 | -- +migrate Up 12 | 13 | -- Tables 14 | CREATE TABLE IF NOT EXISTS "badges" ( 15 | "id" BIGSERIAL, 16 | "slug" VARCHAR(255) UNIQUE NOT NULL, 17 | "title" VARCHAR(255), 18 | "type" VARCHAR(20), 19 | "levels" JSONB, 20 | "created_at" TIMESTAMP WITH TIME ZONE, 21 | "updated_at" TIMESTAMP WITH TIME ZONE, 22 | "deleted_at" TIMESTAMP WITH TIME ZONE, 23 | PRIMARY KEY ("id"), 24 | CONSTRAINT non_empty CHECK (slug <> '') 25 | ); 26 | 27 | ALTER TABLE applications ADD COLUMN "badge_ratings" JSONB; 28 | 29 | -- Indexes 30 | -------------------------------------------------------------------------------- /migrations/v0005.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | -- Remove indexes 4 | DROP INDEX IF EXISTS "idx_contents_name"; 5 | 6 | -- Remove tables 7 | DROP TABLE IF EXISTS "contents"; 8 | 9 | -- +migrate Up 10 | 11 | -- Tables 12 | CREATE TABLE "contents" ( 13 | "id" BIGSERIAL, 14 | "name" VARCHAR(255) NOT NULL DEFAULT '', 15 | "content_type" VARCHAR(128) NOT NULL DEFAULT 'text/plain', 16 | "locale" VARCHAR(255) NOT NULL DEFAULT 'en-GB', 17 | "body" BYTEA NOT NULL DEFAULT '', 18 | "created_at" TIMESTAMP WITH TIME ZONE, 19 | "updated_at" TIMESTAMP WITH TIME ZONE, 20 | "deleted_at" TIMESTAMP WITH TIME ZONE, 21 | PRIMARY KEY ("id") 22 | ); 23 | 24 | CREATE UNIQUE INDEX idx_contents_name 25 | ON "contents" ("name","locale"); 26 | -------------------------------------------------------------------------------- /webui/src/app/components/env-chip/env-chip.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { EnvChipComponent } from './env-chip.component'; 4 | 5 | describe('EnvChipComponent', () => { 6 | let component: EnvChipComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ EnvChipComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(EnvChipComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /webui/src/app/services/data-badge.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ConfigurationService } from './configuration.service'; 3 | import { DefaultResource } from '../interfaces/default-resources.interface'; 4 | import { DataCoreResource } from './data-core-resources.service'; 5 | /** 6 | * data model 7 | */ 8 | import { BadgeBean } from '../models/commons/badges-bean'; 9 | import { HttpClient } from '@angular/common/http'; 10 | 11 | @Injectable() 12 | export class DataBadgeService extends DataCoreResource implements DefaultResource { 13 | constructor( 14 | private _http: HttpClient, 15 | private _configuration: ConfigurationService 16 | ) { 17 | super(_configuration, _configuration.ApiUrl + 'v1/badges', _http); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webui/src/app/services/data-graph.service.ts: -------------------------------------------------------------------------------- 1 | import { DataGraphResource } from './data-graph-resources.service'; 2 | import { Injectable } from '@angular/core'; 3 | import { ConfigurationService } from './configuration.service'; 4 | import { DefaultGraphResource } from '../interfaces/default-resources.interface'; 5 | /** 6 | * data model 7 | */ 8 | import { HttpClient } from '@angular/common/http'; 9 | import { GraphBean } from '../models/graph/graph-bean'; 10 | 11 | @Injectable() 12 | export class DataGraphService extends DataGraphResource implements DefaultGraphResource { 13 | constructor( 14 | private _http: HttpClient, 15 | private _configuration: ConfigurationService 16 | ) { 17 | super(_configuration, _configuration.ApiUrl + 'v1/graphs', _http); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webui/src/app/components/appdetail/appdetail.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AppdetailComponent } from './appdetail.component'; 4 | 5 | describe('AppdetailComponent', () => { 6 | let component: AppdetailComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ AppdetailComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AppdetailComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /webui/src/app/services/data-domain.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ConfigurationService } from './configuration.service'; 3 | import { DefaultResource } from '../interfaces/default-resources.interface'; 4 | import { DataCoreResource } from './data-core-resources.service'; 5 | /** 6 | * data model 7 | */ 8 | import { DomainBean } from '../models/commons/applications-bean'; 9 | import { HttpClient } from '@angular/common/http'; 10 | 11 | @Injectable() 12 | export class DataDomainService extends DataCoreResource implements DefaultResource { 13 | constructor( 14 | private _http: HttpClient, 15 | private _configuration: ConfigurationService 16 | ) { 17 | super(_configuration, _configuration.ApiUrl + 'v1/domains', _http); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scripts/appcatalog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | 5 | ./appcatalog $* & 6 | 7 | wait_for_apis () { 8 | export API_BASE_URL=http://localhost:8081/api 9 | # wait for api 10 | ret=1 11 | while [ $ret -ne 0 ] 12 | do 13 | curl -s $API_BASE_URL/unsecured/version >/dev/null 14 | ret=$? 15 | echo "Return:" $ret 16 | sleep 1 17 | done 18 | } 19 | 20 | # Activate IMPORT_SAMPLE_DATA env var to inject sample data 21 | [ "x${IMPORT_SAMPLE_DATA}" != "x" ] && { 22 | wait_for_apis 23 | ./mycompany.sh >/dev/null 24 | } 25 | 26 | # Any script called init-script.sh will be executed 27 | # Use it for your own integration 28 | [ -f ./init-script.sh ] && { 29 | wait_for_apis 30 | chmod 700 ./init-script.sh && ./init-script.sh 31 | } 32 | 33 | wait %1 34 | 35 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-action-menu/oui-action-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Output, EventEmitter, Input, OnDestroy } from '@angular/core'; 2 | 3 | import { UiKitMenuItem } from '../../models/kit/navbar'; 4 | 5 | @Component({ 6 | selector: 'app-oui-action-menu', 7 | templateUrl: './oui-action-menu.component.html', 8 | styleUrls: ['./oui-action-menu.component.css'], 9 | providers: [] 10 | }) 11 | export class OuiActionMenuComponent implements OnInit { 12 | 13 | visible = true; 14 | @Input() items: UiKitMenuItem; 15 | 16 | @Output() select: EventEmitter = new EventEmitter(); 17 | 18 | constructor( 19 | ) { 20 | } 21 | 22 | ngOnInit() { 23 | } 24 | 25 | onSelect(event: any, tabs: string) { 26 | event.data = tabs; 27 | this.select.emit(event); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /webui/src/app/models/commons/badges-bean.ts: -------------------------------------------------------------------------------- 1 | import { EntityBean, PageMetaData, HrefLinks, AbstractPaginatedResource } from './entity-bean'; 2 | import { Timestamp } from 'rxjs'; 3 | 4 | export class BadgeLevelBean { 5 | id: string; 6 | description: string; 7 | label: string; 8 | color: string; 9 | } 10 | 11 | // Badge 12 | export class BadgeBean extends EntityBean { 13 | slug: string; 14 | title: string; 15 | type: string; 16 | levels: BadgeLevelBean[]; 17 | _links?: HrefLinks[]; 18 | _stats: Map; 19 | } 20 | 21 | // Badge for page browse 22 | export class BadgePagesBean extends AbstractPaginatedResource { 23 | badges: BadgeBean[] = []; 24 | metadata: PageMetaData = { 25 | totalElements: 0, 26 | totalPages: 0, 27 | size: 0, 28 | number: 0 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /webui/src/app/services/data-deployment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { ConfigurationService } from './configuration.service'; 4 | import { DefaultResource } from '../interfaces/default-resources.interface'; 5 | import { DataCoreResource } from './data-core-resources.service'; 6 | /** 7 | * data model 8 | */ 9 | import { DeploymentBean } from '../models/commons/applications-bean'; 10 | 11 | @Injectable() 12 | export class DataDeploymentService extends DataCoreResource implements DefaultResource { 13 | constructor( 14 | private _http: HttpClient, 15 | private _configuration: ConfigurationService 16 | ) { 17 | super(_configuration, _configuration.ApiUrl + 'v1/deployments', _http); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webui/src/app/components/applications/applications.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ApplicationsComponent } from './applications.component'; 4 | 5 | describe('ApplicationsComponent', () => { 6 | let component: ApplicationsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ApplicationsComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ApplicationsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /webui/src/app/services/data-environment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ConfigurationService } from './configuration.service'; 3 | import { DefaultResource } from '../interfaces/default-resources.interface'; 4 | import { DataCoreResource } from './data-core-resources.service'; 5 | /** 6 | * data model 7 | */ 8 | import { EnvironmentBean } from '../models/commons/applications-bean'; 9 | import { HttpClient } from '@angular/common/http'; 10 | 11 | @Injectable() 12 | export class DataEnvironmentService extends DataCoreResource implements DefaultResource { 13 | constructor( 14 | private _http: HttpClient, 15 | private _configuration: ConfigurationService 16 | ) { 17 | super(_configuration, _configuration.ApiUrl + 'v1/environments', _http); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webui/src/app/services/data-application-version.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ConfigurationService } from './configuration.service'; 3 | import { DefaultResource } from '../interfaces/default-resources.interface'; 4 | import { DataCoreResource } from './data-core-resources.service'; 5 | /** 6 | * data model 7 | */ 8 | import { ApplicationBean } from '../models/commons/applications-bean'; 9 | import { HttpClient } from '@angular/common/http'; 10 | 11 | @Injectable() 12 | export class DataApplicationService extends DataCoreResource implements DefaultResource { 13 | constructor( 14 | private _http: HttpClient, 15 | private _configuration: ConfigurationService 16 | ) { 17 | super(_configuration, _configuration.ApiUrl + 'v1/applications', _http); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webui/src/app/shared/decorator/autoUnsubscribe.ts: -------------------------------------------------------------------------------- 1 | export function AutoUnsubscribe( blackList = [] ) { 2 | 3 | return function ( constructor ) { 4 | const original = constructor.prototype.ngOnDestroy; 5 | 6 | constructor.prototype.ngOnDestroy = function () { 7 | for ( const prop in this ) { 8 | if (prop) { 9 | const property = this[ prop ]; 10 | if ( blackList.indexOf(prop) === -1 ) { 11 | if ( property && ( typeof property.unsubscribe === 'function' ) ) { 12 | property.unsubscribe(); 13 | } 14 | } 15 | } 16 | } 17 | return original && typeof original === 'function' && original.apply(this, arguments); 18 | }; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /webui/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './e2e/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: 'e2e/tsconfig.e2e.json' 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## Business processes 4 | 5 | Generate from observation a whole and consistent business process in standard BPM Notation. 6 | 7 | Retry failed steps or place compensatory requests. 8 | 9 | ## Knowledge base 10 | 11 | For each application, provide a Q&A or Board section to allow discussion between maintainers and consumers. 12 | 13 | ## Gamification badges 14 | 15 | Show some badges on an application page to view which metrics could be enhanced to increase quality of service. 16 | 17 | ## Open API sandbox 18 | 19 | If a service provides an API and a sandbox environment, provide a Swagger UI to allow discovering of the API. 20 | 21 | ## Change management 22 | 23 | When a global information system change has to be performed (for instance, RGPD compliance), track the change progress 24 | over all applications of the information system. 25 | -------------------------------------------------------------------------------- /api/db/type.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | // DatabaseInstance host and port 4 | type DatabaseInstance struct { 5 | Port int `json:"port"` 6 | Host string `json:"host"` 7 | Ssl string `json:"sslmode"` 8 | } 9 | 10 | // DatabaseCredentials credentials 11 | type DatabaseCredentials struct { 12 | Readers []DatabaseInstance `json:"readers"` 13 | Writers []DatabaseInstance `json:"writers"` 14 | Database string `json:"database"` 15 | Password string `json:"password"` 16 | User string `json:"user"` 17 | Type string `json:"type"` 18 | } 19 | 20 | // Type db type 21 | type Type string 22 | 23 | const ( 24 | // PostgreSQL connect string 25 | PostgreSQL Type = "user=%s password=%s host=%s port=%d DB.name=%s sslmode=%s" 26 | 27 | // PostgreSQLDefaultSslMode default ssl connect string 28 | PostgreSQLDefaultSslMode = "require" 29 | ) 30 | -------------------------------------------------------------------------------- /webui/src/app/models/graph/graph-bean.ts: -------------------------------------------------------------------------------- 1 | // Vis side object 2 | export class GraphVis { 3 | nodes: VisNode[]; 4 | edges: VisEdge[]; 5 | } 6 | 7 | export class VisNode { 8 | id: string; 9 | label: string; 10 | group: string; 11 | environment: string; 12 | domain: string; 13 | application:string; 14 | } 15 | 16 | export class VisEdge { 17 | id: string; 18 | from: string; 19 | to: string; 20 | label: string; 21 | } 22 | 23 | // Server side object 24 | export class GraphBean { 25 | nodes: NodeBean[]; 26 | edges: EdgeBean[]; 27 | options: any; 28 | } 29 | 30 | export class NodeBean { 31 | id: string; 32 | name: string; 33 | type: string; 34 | properties: any; 35 | } 36 | 37 | export class EdgeBean { 38 | id: string; 39 | from: string; 40 | to: string; 41 | type: string; 42 | properties: any; 43 | } 44 | -------------------------------------------------------------------------------- /webui/src/app/widget/help-widget/help-widget.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Output, EventEmitter, Input, ElementRef, OnDestroy, Renderer2 } from '@angular/core'; 2 | import { DomHandler } from 'primeng/primeng'; 3 | import { HelpsStoreService } from '../../stores/help-store.service'; 4 | 5 | @Component({ 6 | selector: 'app-help-widget', 7 | templateUrl: './help-widget.component.html', 8 | styleUrls: ['./help-widget.component.css'], 9 | providers: [DomHandler] 10 | }) 11 | export class HelpWidgetComponent implements OnInit { 12 | 13 | private _key = ''; 14 | @Output() select: EventEmitter = new EventEmitter(); 15 | 16 | constructor( 17 | public help: HelpsStoreService 18 | ) { 19 | } 20 | 21 | ngOnInit() { 22 | } 23 | 24 | public get key() { 25 | return this._key; 26 | } 27 | 28 | @Input() public set key(val: string) { 29 | this._key = val; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/v1/graph/handlers.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/loopfz/gadgeto/tonic" 9 | "github.com/ovh/lhasa/api/graphapi" 10 | "github.com/ovh/lhasa/api/v1" 11 | "github.com/ovh/lhasa/api/v1/deployment" 12 | ) 13 | 14 | // HandlerGraph returns a dependency graph for a given deployment 15 | func HandlerGraph(repo *Repository, depRepo *deployment.Repository) gin.HandlerFunc { 16 | return tonic.Handler(func(c *gin.Context, deployment *v1.Deployment) (*graphapi.Graph, error) { 17 | entity, err := depRepo.FindOneBy(map[string]interface{}{"public_id": deployment.PublicID}) 18 | if err != nil { 19 | return nil, err 20 | } 21 | deployment, ok := entity.(*v1.Deployment) 22 | if !ok { 23 | return nil, errors.New("internal type error") 24 | } 25 | 26 | return repo.FindByDeployment(deployment.PublicID) 27 | }, http.StatusOK) 28 | } 29 | -------------------------------------------------------------------------------- /webui/src/app/components/badges/badges.component.html: -------------------------------------------------------------------------------- 1 | 2 |

{{ 'BADGES' | translate }}

3 |
4 | 5 | 6 |
7 |
{{level.id}}
8 |
9 | → 10 | 11 |
12 |
13 |
14 |
15 | 16 | -------------------------------------------------------------------------------- /webui/src/app/interfaces/default-resources.interface.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | /** 4 | * data model 5 | */ 6 | import { EntityBean, ContentListResponse } from '../models/commons/entity-bean'; 7 | 8 | export interface DefaultResource { 9 | GetAll(): Observable; 10 | GetAllFromContent(filter: string, params: {[key: string]: any | any[]}): Observable>; 11 | GetSingle(id: string): Observable; 12 | GetSingleAny(id: string): Observable; 13 | Task(path: String, payload: any): Observable; 14 | Add(itemToAdd: T): Observable; 15 | Update(id: string, itemToUpdate: T): Observable; 16 | Delete(id: string): Observable; 17 | } 18 | 19 | export interface DefaultStreamResource { 20 | GetSingle(id: string): Observable; 21 | } 22 | 23 | export interface DefaultGraphResource { 24 | Get(params: any): Observable; 25 | } 26 | -------------------------------------------------------------------------------- /webui/src/app/services/data-content.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Http, Response, Headers } from '@angular/http'; 3 | import { ConfigurationService } from './configuration.service'; 4 | import { DefaultResource, DefaultStreamResource } from '../interfaces/default-resources.interface'; 5 | import { DataCoreResource } from './data-core-resources.service'; 6 | 7 | /** 8 | * data model 9 | */ 10 | import { ContentBean } from '../models/commons/content-bean'; 11 | import { HttpClient } from '@angular/common/http'; 12 | import { DataStreamResource } from './data-stream-resources.service'; 13 | 14 | @Injectable() 15 | export class DataContentService extends DataStreamResource implements DefaultStreamResource { 16 | constructor( 17 | private _http: HttpClient, 18 | private _configuration: ConfigurationService 19 | ) { 20 | super(_configuration, _configuration.ApiUrl + 'v1/contents', _http); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/db/migrations.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/gobuffalo/packr" 7 | "github.com/rubenv/sql-migrate" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var migrations = &migrate.PackrMigrationSource{ 12 | Box: packr.NewBox("../../migrations"), 13 | } 14 | 15 | func init() { 16 | migrate.SetTable("ovh_sql_schema_migrations") 17 | } 18 | 19 | // MigrateUp run sql-migrate migrations 20 | func MigrateUp(db *sql.DB, log logrus.FieldLogger) error { 21 | count, err := migrate.Exec(db, "postgres", migrations, migrate.Up) 22 | if err != nil { 23 | return err 24 | } 25 | log.Warnf("Applied %d migrations", count) 26 | return nil 27 | } 28 | 29 | // MigrateDown run sql-migrate migrations 30 | func MigrateDown(db *sql.DB, log logrus.FieldLogger) error { 31 | count, err := migrate.Exec(db, "postgres", migrations, migrate.Down) 32 | if err != nil { 33 | return err 34 | } 35 | log.Warnf("Removed %d migrations", count) 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-pagination/oui-pagination.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 6 |
7 | 9 |
10 | 13 |
14 |
15 | -------------------------------------------------------------------------------- /webui/src/app/widget/badgewidget/badgewidget.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Output, EventEmitter, Input, ElementRef, OnDestroy, Renderer2 } from '@angular/core'; 2 | import { DomHandler } from 'primeng/primeng'; 3 | 4 | // BadgeShieldsIOBean for a badge SVG representation 5 | export class BadgeShieldsIOBean { 6 | title: string; 7 | value: string; 8 | comment: string; 9 | label: string; 10 | color: string; 11 | description: string; 12 | } 13 | 14 | @Component({ 15 | selector: 'app-badge-shieldsio', 16 | templateUrl: './badgewidget.component.html', 17 | styleUrls: ['./badgewidget.component.css'], 18 | providers: [DomHandler] 19 | }) 20 | export class BadgeWidgetComponent implements OnInit { 21 | 22 | _badge: BadgeShieldsIOBean; 23 | 24 | @Output() select: EventEmitter = new EventEmitter(); 25 | 26 | constructor( 27 | ) { 28 | } 29 | 30 | ngOnInit() { 31 | } 32 | 33 | @Input() set badge(val: BadgeShieldsIOBean) { 34 | this._badge = val; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/graphapi/graph.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/loopfz/gadgeto/tonic" 8 | ) 9 | 10 | // HandlerFindAllActive returns a resource list 11 | func HandlerFindAllActive(repository Repository) gin.HandlerFunc { 12 | return tonic.Handler(func(c *gin.Context) (*Graph, error) { 13 | // params and query are user to filter resultset 14 | graphResult, err := repository.FindAllActive(nil) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return graphResult, nil 20 | }, http.StatusOK) 21 | } 22 | 23 | // Convert to dependencies node behaviour 24 | func Convert(repo Repository, entities []interface{}) []ImplementNode { 25 | var nodes = []ImplementNode{} 26 | for _, entity := range entities { 27 | mappers := map[string]ImplementNode{} 28 | // Resolve child dependencies 29 | repo.Resolve(entity, mappers) 30 | // Add this resolved node 31 | nodes = append(nodes, repo.Map(entity, mappers)) 32 | } 33 | return nodes 34 | } 35 | -------------------------------------------------------------------------------- /api/tests/fixtures.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "net/http/httptest" 5 | 6 | mocket "github.com/Selvatico/go-mocket" 7 | "github.com/gobwas/glob" 8 | "github.com/ovh/lhasa/api/config" 9 | "github.com/ovh/lhasa/api/db" 10 | "github.com/ovh/lhasa/api/security" 11 | 12 | "github.com/jinzhu/gorm" 13 | 14 | "github.com/ovh/lhasa/api/logger" 15 | "github.com/ovh/lhasa/api/routing" 16 | ) 17 | 18 | // StartTestHTTPServer starts a fake http server for testing purposes 19 | func StartTestHTTPServer() *httptest.Server { 20 | log := logger.NewLogger(true, true, false, false) 21 | mocket.Catcher.Register() 22 | mocket.Catcher.Reset() 23 | mocket.Catcher.Logging = true 24 | dbHandle, _ := gorm.Open(mocket.DRIVER_NAME, "any_string") 25 | tm := db.NewTransactionManager(dbHandle) 26 | c := config.Lhasa{Policy: security.Policy{"ROLE_ADMIN": {"X-Remote-User": {glob.MustCompile("*")}}}} 27 | router := routers.NewRouter(tm, c, "1.0.0", "/api", "/ui", "/", "./", true, log) 28 | server := httptest.NewServer(router) 29 | return server 30 | } 31 | -------------------------------------------------------------------------------- /webui/src/app/components/openapi-ui/openapi-ui.component.ts: -------------------------------------------------------------------------------- 1 | import { OnChanges, Component, ElementRef, Input, ViewChild, AfterViewInit } from '@angular/core'; 2 | 3 | import SwaggerUI from 'swagger-ui'; 4 | 5 | @Component({ 6 | selector: 'app-openapi-ui', 7 | templateUrl: './openapi-ui.component.html', 8 | styleUrls: [] 9 | }) 10 | export class OpenAPIUIComponent implements AfterViewInit { 11 | 12 | _url: string; 13 | @ViewChild('openapi') targetdiv: ElementRef; 14 | 15 | constructor(private el: ElementRef) { 16 | } 17 | 18 | @Input() set url(val: string) { 19 | this._url = val; 20 | this.apply(); 21 | } 22 | 23 | ngAfterViewInit() { 24 | this.apply(); 25 | } 26 | 27 | apply() { 28 | if (!this._url || !this.targetdiv) { 29 | return; 30 | } 31 | SwaggerUI({ 32 | url: this._url, 33 | domNode: this.targetdiv.nativeElement, 34 | deepLinking: false, 35 | validatorUrl: null, 36 | displayRequestDuration: true, 37 | presets: [ 38 | SwaggerUI.presets.apis 39 | ], 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /webui/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client:{ 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ], 20 | fixWebpackSourcePaths: true 21 | }, 22 | 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /api/v1/environment/handlers.go: -------------------------------------------------------------------------------- 1 | package environment 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/loopfz/gadgeto/tonic" 8 | "github.com/ovh/lhasa/api/hateoas" 9 | "github.com/ovh/lhasa/api/v1" 10 | ) 11 | 12 | // HandlerCreate replace or create a resource 13 | func HandlerCreate(repository *Repository) gin.HandlerFunc { 14 | return tonic.Handler(func(c *gin.Context, env *v1.Environment) (*v1.Environment, error) { 15 | oldres, err := repository.FindOneByUnscoped(map[string]interface{}{"slug": env.Slug}) 16 | oldenv := oldres.(*v1.Environment) 17 | if hateoas.IsEntityDoesNotExistError(err) { 18 | if err := repository.Save(env); err != nil { 19 | return nil, err 20 | } 21 | return nil, hateoas.ErrorCreated 22 | } 23 | if err != nil { 24 | return nil, err 25 | } 26 | env.ID = oldenv.ID 27 | env.CreatedAt = oldenv.CreatedAt 28 | if err := repository.Save(env); err != nil { 29 | return nil, err 30 | } 31 | if oldenv.DeletedAt != nil { 32 | return env, hateoas.ErrorCreated 33 | } 34 | return env, nil 35 | }, http.StatusOK) 36 | } 37 | -------------------------------------------------------------------------------- /webui/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /migrations/v0002.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | DROP TABLE IF EXISTS "deployments"; 4 | DROP TABLE IF EXISTS "environments"; 5 | 6 | -- +migrate Up 7 | 8 | CREATE TABLE "environments" ( 9 | "id" BIGSERIAL, 10 | "slug" VARCHAR(255) NOT NULL DEFAULT '', 11 | "name" VARCHAR(255), 12 | "properties" JSONB, 13 | "created_at" TIMESTAMP WITH TIME ZONE, 14 | "updated_at" TIMESTAMP WITH TIME ZONE, 15 | "deleted_at" TIMESTAMP WITH TIME ZONE, 16 | PRIMARY KEY ("id") 17 | ); 18 | 19 | CREATE TABLE "deployments" ( 20 | "id" BIGSERIAL, 21 | "public_id" VARCHAR(255), 22 | "environment_id" BIGINT NOT NULL DEFAULT 0, 23 | "application_id" BIGINT NOT NULL DEFAULT 0, 24 | "properties" JSONB, 25 | "created_at" TIMESTAMP WITH TIME ZONE, 26 | "undeployed_at" TIMESTAMP WITH TIME ZONE, 27 | "updated_at" TIMESTAMP WITH TIME ZONE, 28 | "deleted_at" TIMESTAMP WITH TIME ZONE, 29 | PRIMARY KEY ("id"), 30 | FOREIGN KEY ("environment_id") REFERENCES "environments" ON DELETE CASCADE, 31 | FOREIGN KEY ("application_id") REFERENCES "applications" ON DELETE CASCADE 32 | ); 33 | -------------------------------------------------------------------------------- /webui/Makefile: -------------------------------------------------------------------------------- 1 | UI_BASE_HREF ?= / 2 | 3 | all: ui-kit 4 | ng build --output-path=../dist/webui --prod --aot --configuration production --base-href '{{ .UIBasePath }}' --deploy-url ${UI_BASE_HREF} 5 | 6 | dev: ui-kit dep 7 | ng build --output-path=../dist/webui 8 | 9 | ui-kit: dep 10 | @rm -rf ../dist/ovh-ui-kit 11 | @mkdir -p ../dist/ovh-ui-kit 12 | @echo copy ovh-ui-kit dist icons 13 | @cp -rfp ./node_modules//ovh-ui-kit/dist/icons ../dist/ovh-ui-kit 14 | @echo copy ovh-ui-kit dist fonts 15 | @cp -rfp ./node_modules//ovh-ui-kit/packages/oui-typography/fonts ../dist/ovh-ui-kit 16 | @echo copy ovh-ui-kit css 17 | @cp ./node_modules/ovh-ui-kit/dist/oui.css ../dist/ovh-ui-kit/ovh-ui-kit.css 18 | @echo trick relative resouce location 19 | @sed s:../../dist/::g ../dist/ovh-ui-kit/ovh-ui-kit.css > ../dist/ovh-ui-kit/ovh-ui-kit.css.tmp 20 | @mv -f ../dist/ovh-ui-kit/ovh-ui-kit.css.tmp ../dist/ovh-ui-kit/ovh-ui-kit.css 21 | 22 | dep: 23 | npm install 24 | 25 | test: dep 26 | ng test 27 | ng e2e 28 | 29 | run: all 30 | npm start 31 | 32 | live: 33 | npm start 34 | 35 | clean: 36 | echo "no cleaning command available" 37 | -------------------------------------------------------------------------------- /webui/src/app/pipes/pipes-applications.component.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { ApplicationBean, DomainBean } from '../models/commons/applications-bean'; 3 | 4 | import { sortBy } from 'lodash'; 5 | 6 | @Pipe({ 7 | name: 'orderByDomains' 8 | }) 9 | export class DomainSortPipe implements PipeTransform { 10 | transform(domains: Array): Array { 11 | const ordered = sortBy(domains, (domain) => { 12 | return this.pad(domain.name, 'a', 64); 13 | }); 14 | return ordered; 15 | } 16 | 17 | pad(str, padString, length) { 18 | while (str.length < length) { 19 | str = str + padString; 20 | } 21 | return str; 22 | } 23 | } 24 | 25 | @Pipe({ 26 | name: 'orderByApps' 27 | }) 28 | export class ApplicationSortPipe implements PipeTransform { 29 | transform(applications: Array): Array { 30 | const ordered = sortBy(applications, (app) => { 31 | return this.pad(app.name, 'a', 64) + this.pad(app.domain, 'a', 64); 32 | }); 33 | return ordered; 34 | } 35 | 36 | pad(str, padString, length) { 37 | while (str.length < length) { 38 | str = str + padString; 39 | } 40 | return str; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /webui/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | describe('AppComponent', () => { 5 | beforeEach(async(() => { 6 | TestBed.configureTestingModule({ 7 | imports: [ 8 | RouterTestingModule 9 | ], 10 | declarations: [ 11 | AppComponent 12 | ], 13 | }).compileComponents(); 14 | })); 15 | it('should create the app', async(() => { 16 | const fixture = TestBed.createComponent(AppComponent); 17 | const app = fixture.debugElement.componentInstance; 18 | expect(app).toBeTruthy(); 19 | })); 20 | it(`should have as title 'app'`, async(() => { 21 | const fixture = TestBed.createComponent(AppComponent); 22 | const app = fixture.debugElement.componentInstance; 23 | expect(app.title).toEqual('app'); 24 | })); 25 | it('should render title in a h1 tag', async(() => { 26 | const fixture = TestBed.createComponent(AppComponent); 27 | fixture.detectChanges(); 28 | const compiled = fixture.debugElement.nativeElement; 29 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); 30 | })); 31 | }); 32 | -------------------------------------------------------------------------------- /webui/src/app/services/security.service.ts: -------------------------------------------------------------------------------- 1 | 2 | import {catchError} from 'rxjs/operators'; 3 | import { Injectable } from '@angular/core'; 4 | 5 | import { Observable } from 'rxjs'; 6 | import { Router } from '@angular/router'; 7 | 8 | import { ConfigurationService } from '../services/configuration.service'; 9 | import { DefaultResource } from '../interfaces/default-resources.interface'; 10 | /** 11 | * data model 12 | */ 13 | import { EntityBean } from '../models/commons/entity-bean'; 14 | import { DataCoreResource } from './data-core-resources.service'; 15 | import { HttpClient } from '@angular/common/http'; 16 | 17 | @Injectable() 18 | export class SecurityService extends DataCoreResource implements DefaultResource { 19 | 20 | /** 21 | * constructor 22 | */ 23 | constructor( 24 | private _http: HttpClient, 25 | private _router: Router, 26 | private _ConfigurationService: ConfigurationService) { 27 | super(_ConfigurationService, _ConfigurationService.ApiUrl, _http); 28 | } 29 | 30 | /** 31 | * get connect resource 32 | */ 33 | public Connect = (): Observable => { 34 | return this.http.get(this.actionUrl + 'api/connect', {headers: this.headers}).pipe( 35 | catchError(this.handleError)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/security/type.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/gobwas/glob" 7 | ) 8 | 9 | // Role defines an applicative Role 10 | type Role string 11 | 12 | // Policy defines a match table between roles, http headers and allowed values 13 | type Policy map[Role]map[string][]interface{} 14 | 15 | // RolePolicy defines an indexed list of Role 16 | type RolePolicy map[Role]bool 17 | 18 | // Existing roles 19 | const ( 20 | RoleAdmin Role = "ROLE_ADMIN" 21 | RoleUser = "ROLE_USER" 22 | RoleBadgeCreator = "ROLE_BADGE_CREATOR" 23 | ) 24 | 25 | // UnmarshalJSON implements json.Unmarshaler interface 26 | func (p Policy) UnmarshalJSON(raw []byte) error { 27 | var jsonPolicy map[Role]map[string][]string 28 | if err := json.Unmarshal(raw, &jsonPolicy); err != nil { 29 | return err 30 | } 31 | for role, headers := range jsonPolicy { 32 | p[role] = map[string][]interface{}{} 33 | for header, patterns := range headers { 34 | p[role][header] = []interface{}{} 35 | for _, pattern := range patterns { 36 | var v interface{} = pattern 37 | g, err := glob.Compile(pattern) 38 | if err == nil { 39 | v = g 40 | } 41 | p[role][header] = append(p[role][header], v) 42 | } 43 | } 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /migrations/v0001.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | -- Remove indexes 4 | DROP INDEX IF EXISTS "idx_applications_domain_name_version"; 5 | DROP INDEX IF EXISTS "idx_applications_tags"; 6 | 7 | -- Remove tables 8 | DROP TABLE IF EXISTS "dependencies"; 9 | DROP TABLE IF EXISTS "applications"; 10 | 11 | -- +migrate Up 12 | 13 | -- Tables 14 | CREATE TABLE "applications" ( 15 | "id" BIGSERIAL, 16 | "domain" VARCHAR(255) NOT NULL DEFAULT '', 17 | "name" VARCHAR(255) NOT NULL DEFAULT '', 18 | "version" VARCHAR(255) NOT NULL DEFAULT '', 19 | "tags" VARCHAR(255) [], 20 | "manifest" JSONB, 21 | "created_at" TIMESTAMP WITH TIME ZONE, 22 | "updated_at" TIMESTAMP WITH TIME ZONE, 23 | "deleted_at" TIMESTAMP WITH TIME ZONE, 24 | PRIMARY KEY ("id") 25 | ); 26 | 27 | CREATE UNIQUE INDEX idx_applications_domain_name_version 28 | ON "applications" ("domain", "name", "version"); 29 | 30 | CREATE INDEX idx_applications_tags 31 | ON "applications" USING GIN ("tags"); 32 | 33 | CREATE TABLE "dependencies" ( 34 | "id" BIGSERIAL, 35 | "owner_id" BIGINT NOT NULL DEFAULT 0, 36 | "target_id" BIGINT NOT NULL DEFAULT 0, 37 | PRIMARY KEY ("id"), 38 | FOREIGN KEY ("owner_id") REFERENCES "applications" ON DELETE CASCADE, 39 | FOREIGN KEY ("target_id") REFERENCES "applications" ON DELETE CASCADE 40 | ); 41 | -------------------------------------------------------------------------------- /webui/README.md: -------------------------------------------------------------------------------- 1 | # appcatalog 2 | 3 | ## Technical overview 4 | 5 | This project is currently at very early stage and under active development. It is mainly written in golang and in angular5 6 | 7 | ## Contact 8 | 9 | ### Authors 10 | 11 | * Rayene Ben Rayana 12 | * Fabien Meurillon 13 | * Yannick Roffin 14 | 15 | ## How to build 16 | 17 | make 18 | 19 | ### Build requirements 20 | 21 | * Git 22 | * [Go installation](https://golang.org/doc/install) and [workspace](https://golang.org/doc/code.html#Workspaces) (`GOROOT` and `GOPATH` correctly set) 23 | * GNU Make 24 | * [dep](https://github.com/golang/dep) - Go dependency management tool 25 | * [Angular 5 CLI](https://angular.io/guide/quickstart) - Management CLI for angular 5 26 | 27 | ### Steps 28 | 29 | ``` 30 | cd $GOPATH/src/github.com/ovh 31 | git clone git@github.com/ovh/lhasa.git 32 | cd lhasa && make 33 | ``` 34 | 35 | ### Run 36 | 37 | #### Locally 38 | 39 | ``` 40 | cd $GOPATH/src/github.com/ovh/lhasa 41 | make run 42 | ``` 43 | 44 | #### Locally in full stack mode 45 | 46 | ``` 47 | cd $GOPATH/src/github.com/ovh/lhasa/webui 48 | make live 49 | ``` 50 | 51 | ``` 52 | cd $GOPATH/src/github.com/ovh/lhasa/api 53 | make live 54 | ``` 55 | 56 | ``` 57 | cd $GOPATH/src/github.com/ovh/lhasa-companion/api 58 | make live 59 | ``` 60 | -------------------------------------------------------------------------------- /webui/src/app/components/applications/applications.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |

{{ 'APPLICATIONS' | translate }} ({{metadata.totalElements}})

6 | 7 |
8 |

{{ domain.name | uppercase }}

9 |
10 | 11 |

12 | {{ app.description }} 13 |

14 |
15 |
16 |
17 |
18 | {{ "YOUR_SEARCH_QUERY_RETURNED_0_RESULTS" | translate }} 19 |
20 | 21 | -------------------------------------------------------------------------------- /api/v1/deployment/depend.go: -------------------------------------------------------------------------------- 1 | package deployment 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/ovh/lhasa/api/hateoas" 7 | "github.com/ovh/lhasa/api/v1" 8 | ) 9 | 10 | // Depend deploys an application version to the given environment and removes old deployments 11 | type Depend func(src *v1.Deployment, target *v1.Deployment, t string) error 12 | 13 | // Dependency declare an observable dependency on one application 14 | func Dependency(depRepo *Repository) Depend { 15 | return func(src *v1.Deployment, target *v1.Deployment, dependencyType string) error { 16 | dependencies := make([]v1.DeploymentDependency, 0) 17 | // Check current value 18 | if src.Dependencies.RawMessage != nil { 19 | if err := json.Unmarshal(src.Dependencies.RawMessage, &dependencies); err != nil { 20 | return err 21 | } 22 | } 23 | // Check if link already exist 24 | var alreadyStored = false 25 | for _, deps := range dependencies { 26 | if deps.TargetID == target.PublicID { 27 | alreadyStored = true 28 | } 29 | } 30 | // Check if already stored 31 | if alreadyStored { 32 | // No update is needed 33 | return nil 34 | } 35 | dependencies = append(dependencies, v1.DeploymentDependency{TargetID: target.PublicID, Type: dependencyType}) 36 | var err error 37 | src.Dependencies.RawMessage, err = json.Marshal(dependencies) 38 | if err != nil { 39 | return &(hateoas.InternalError{Message: err.Error(), Detail: src.PublicID}) 40 | } 41 | return depRepo.Save(src) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/db/transactions.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "github.com/sirupsen/logrus" 6 | ) 7 | 8 | // TransactionManager allows to control a DB transaction 9 | type TransactionManager interface { 10 | DB() *gorm.DB 11 | Transaction(func(*gorm.DB) error, logrus.FieldLogger) error 12 | } 13 | 14 | type transactionManager struct { 15 | db *gorm.DB 16 | } 17 | 18 | // NewTransactionManager returns a new TransactionManager 19 | func NewTransactionManager(db *gorm.DB) TransactionManager { 20 | return &transactionManager{db: db} 21 | } 22 | 23 | // DB returns the database backend 24 | func (tm *transactionManager) DB() *gorm.DB { 25 | return tm.db 26 | } 27 | 28 | // Transaction embed a callback in a db transaction 29 | // if the callback returns an error, the transaction is rollbacked 30 | func (tm *transactionManager) Transaction(callback func(db *gorm.DB) error, log logrus.FieldLogger) error { 31 | log.Debug("opening transaction") 32 | tx := tm.db.Begin() 33 | tx.SetLogger(getLogger(log)) 34 | if err := tx.Error; err != nil { 35 | return err 36 | } 37 | if err := callback(tx); err != nil { 38 | if err := tx.Rollback().Error; err != nil { 39 | log.WithError(err).Warn("transaction has not been rollbacked") 40 | return err 41 | } 42 | log.Warnf("transaction has been rollbacked") 43 | return err 44 | } 45 | log.Debug("committing transaction") 46 | err := tx.Commit().Error 47 | if err == nil { 48 | log.Debug("transaction has been committed") 49 | } 50 | return err 51 | } 52 | -------------------------------------------------------------------------------- /webui/src/app/components/env-chip/env-chip.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { EnvironmentsStoreService } from '../../stores/environments-store.service'; 3 | import { EnvironmentBean } from '../../models/commons/applications-bean'; 4 | import { Store } from '@ngrx/store'; 5 | import { SubscriptionLike as ISubscription } from 'rxjs'; 6 | import { AutoUnsubscribe } from '../../shared/decorator/autoUnsubscribe'; 7 | import { Observable } from 'rxjs'; 8 | 9 | @Component({ 10 | selector: 'app-env-chip', 11 | templateUrl: './env-chip.component.html', 12 | styleUrls: ['./env-chip.component.css', './flags.css'], 13 | }) 14 | @AutoUnsubscribe() 15 | export class EnvChipComponent implements OnInit { 16 | 17 | @Input() slug: string; 18 | 19 | public blankImageURL = require('./blank.gif'); 20 | 21 | protected environmentStream: Observable>; 22 | protected environmentSubscription: ISubscription; 23 | 24 | public environments: Map; 25 | 26 | constructor( 27 | private environmentsStoreService: EnvironmentsStoreService) { 28 | this.environmentStream = this.environmentsStoreService.environments(); 29 | } 30 | 31 | ngOnInit() { 32 | this.environmentSubscription = this.environmentStream.subscribe( 33 | (element: Map) => { 34 | this.environments = element; 35 | }, 36 | error => { 37 | console.error(error); 38 | }, 39 | () => { 40 | } 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/security/policy.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gobwas/glob" 7 | ) 8 | 9 | // HasOne returns a gin handler that checks the request against the given role 10 | func (policy RolePolicy) HasOne(roles ...Role) bool { 11 | for _, role := range roles { 12 | if policy[role] { 13 | return true 14 | } 15 | } 16 | return false 17 | } 18 | 19 | // HasAll returns a gin handler that checks the request against the given role 20 | func (policy RolePolicy) HasAll(roles ...Role) bool { 21 | for _, role := range roles { 22 | if !policy[role] { 23 | return false 24 | } 25 | } 26 | return true 27 | } 28 | 29 | // ToSlice returns a slice of roles 30 | func (policy RolePolicy) ToSlice() (roles []Role) { 31 | for role, ok := range policy { 32 | if ok { 33 | roles = append(roles, role) 34 | } 35 | } 36 | return 37 | } 38 | 39 | // BuildRolePolicy returns a map of Role matching the given http request 40 | func BuildRolePolicy(policy Policy, r *http.Request) RolePolicy { 41 | if r == nil { 42 | return nil 43 | } 44 | roles := map[Role]bool{} 45 | for role, headers := range policy { 46 | for header, patterns := range headers { 47 | for _, pattern := range patterns { 48 | if checkPattern(pattern, r.Header.Get(header)) { 49 | roles[role] = true 50 | } 51 | } 52 | } 53 | } 54 | return roles 55 | } 56 | 57 | func checkPattern(pattern interface{}, value string) bool { 58 | switch p := pattern.(type) { 59 | case glob.Glob: 60 | return p.Match(value) 61 | case string: 62 | return value == p 63 | } 64 | return false 65 | } 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, OVH SAS 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /api/db/logger.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/jinzhu/gorm" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func getLogger(log logrus.FieldLogger) gorm.Logger { 15 | return gorm.Logger{LogWriter: dbLogWriter{log}} 16 | } 17 | 18 | type dbLogWriter struct { 19 | log logrus.FieldLogger 20 | } 21 | 22 | // Println implements gorm's LogWriter interface 23 | func (l dbLogWriter) Println(v ...interface{}) { 24 | if l.log == nil { 25 | return 26 | } 27 | if len(v) != 5 { 28 | return 29 | } 30 | 31 | var message interface{} = v 32 | file := strings.Split(strings.TrimSuffix(strings.TrimPrefix(v[0].(string), "\u001b[35m("), ")\u001b[0m"), ":") 33 | durationStr := strings.TrimSuffix(strings.TrimPrefix(v[2].(string), " \u001b[36;1m["), "]\u001b[0m ") 34 | rows, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(v[4].(string), " \n\u001b[36;31m["), " rows affected or returned ]\u001b[0m ")) 35 | duration, _ := time.ParseDuration(durationStr) 36 | line, _ := strconv.Atoi(file[1]) 37 | fields := logrus.Fields{ 38 | "file": file[0], 39 | "line": line, 40 | "duration": duration.Seconds(), 41 | "full_message": v[3], 42 | "rows": rows, 43 | } 44 | switch v[3].(type) { 45 | case string: 46 | message = fmt.Sprintf("sql query: %s...", v[3].(string)[:int(math.Min(50, float64(len(v[3].(string)))))]) 47 | l.log.WithFields(fields).Debug(message) 48 | case error: 49 | l.log.WithFields(fields).WithError(v[3].(error)).Error(message) 50 | default: 51 | l.log.WithFields(fields).Debug(message) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /api/v1/badge/handlers.go: -------------------------------------------------------------------------------- 1 | package badge 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/loopfz/gadgeto/tonic" 8 | "github.com/ovh/lhasa/api/hateoas" 9 | "github.com/ovh/lhasa/api/v1" 10 | ) 11 | 12 | // HandlerCreate replace or create a resource 13 | func HandlerCreate(repository *Repository) gin.HandlerFunc { 14 | return tonic.Handler(func(c *gin.Context, bdg *v1.Badge) (*v1.Badge, error) { 15 | _, err := GetDefaultLevel(bdg) 16 | if err != nil { 17 | return nil, err 18 | } 19 | oldres, err := repository.FindOneByUnscoped(map[string]interface{}{"slug": bdg.Slug}) 20 | if hateoas.IsEntityDoesNotExistError(err) { 21 | if err := repository.Save(bdg); err != nil { 22 | return nil, err 23 | } 24 | return nil, hateoas.ErrorCreated 25 | } 26 | if err != nil { 27 | return nil, err 28 | } 29 | oldbdg := oldres.(*v1.Badge) 30 | bdg.ID = oldbdg.ID 31 | bdg.CreatedAt = oldbdg.CreatedAt 32 | if err := repository.Save(bdg); err != nil { 33 | return nil, err 34 | } 35 | if oldbdg.DeletedAt != nil { 36 | return bdg, hateoas.ErrorCreated 37 | } 38 | return bdg, nil 39 | }, http.StatusOK) 40 | } 41 | 42 | // HandlerStats computes and renders badge statistics 43 | func HandlerStats(repo *Repository) gin.HandlerFunc { 44 | return tonic.Handler(func(c *gin.Context, b *v1.Badge) (map[string]int, error) { 45 | bdg, err := repo.FindOneBySlug(b.Slug) 46 | if err != nil { 47 | return nil, err 48 | } 49 | l, err := GetDefaultLevel(bdg) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return repo.GatherStats(b.Slug, l.ID) 54 | }, http.StatusOK) 55 | } 56 | -------------------------------------------------------------------------------- /api/Gopkg.toml: -------------------------------------------------------------------------------- 1 | required = ["github.com/ovh/venom", "gopkg.in/alecthomas/gometalinter.v2"] 2 | 3 | [[constraint]] 4 | name = "github.com/gin-gonic/gin" 5 | branch = "master" 6 | 7 | [[constraint]] 8 | branch = "master" 9 | name = "github.com/loopfz/gadgeto" 10 | 11 | [[constraint]] 12 | name = "github.com/jinzhu/gorm" 13 | version = "~1.9" 14 | 15 | [[constraint]] 16 | name = "github.com/gin-gonic/contrib" 17 | branch = "master" 18 | 19 | [[constraint]] 20 | name = "gopkg.in/alecthomas/kingpin.v2" 21 | version = "~2.2" 22 | 23 | [[constraint]] 24 | branch = "master" 25 | name = "github.com/juju/errors" 26 | 27 | [[constraint]] 28 | branch = "master" 29 | name = "github.com/rubenv/sql-migrate" 30 | 31 | [[constraint]] 32 | name = "github.com/gobuffalo/packr" 33 | version = "~1.10" 34 | 35 | [[override]] 36 | name = "github.com/satori/go.uuid" 37 | branch = "master" 38 | 39 | [[constraint]] 40 | name = "github.com/fabienm/go-logrus-formatters" 41 | version = "~1.0.0" 42 | 43 | [[constraint]] 44 | name = "github.com/wI2L/fizz" 45 | version = "~0.10.2" 46 | 47 | [[constraint]] 48 | name = "github.com/Selvatico/go-mocket" 49 | version = "~1.0.4" 50 | 51 | 52 | [[constraint]] 53 | name = "github.com/gavv/httpexpect" 54 | branch = "v1" 55 | 56 | [[constraint]] 57 | name = "github.com/coreos/go-semver" 58 | version = "~0.2.0" 59 | 60 | [[override]] 61 | name = "github.com/valyala/fasthttp" 62 | branch = "master" 63 | 64 | [[constraint]] 65 | name = "github.com/gobwas/glob" 66 | version = "0.2.3" 67 | 68 | [[constraint]] 69 | name = "github.com/evanphx/json-patch" 70 | version = "3.0.0" 71 | -------------------------------------------------------------------------------- /tests/11-applications-latest-v1.yml: -------------------------------------------------------------------------------- 1 | name: Applications Endpoint TestSuite 2 | vars: 3 | baseroute: '{{.APP_HOST}}/api/v1' 4 | appsroute: '{{.baseroute}}/applications' 5 | testcases: 6 | - name: ApplicationLifecycle 7 | steps: 8 | - type: http 9 | method: DELETE 10 | url: '{{.APP_HOST}}/api/v1/applications' 11 | headers: 12 | assertions: 13 | - result.statuscode ShouldEqual 204 14 | - type: http 15 | method: PUT 16 | url: "{{.appsroute}}/agora/api/versions/0.0.10+cds.432" 17 | body: '{"domain": "agora", "name":"api", "version":"0.0.10+cds.432", "manifest":{"description":"Sample app"}}' 18 | headers: 19 | assertions: 20 | - result.statuscode ShouldEqual 201 21 | - type: http 22 | method: GET 23 | url: "{{.appsroute}}/agora/api/latest" 24 | headers: 25 | assertions: 26 | - result.statuscode ShouldEqual 200 27 | - type: http 28 | method: PUT 29 | url: "{{.appsroute}}/agora/api/versions/0.1.10+cds.101" 30 | body: '{"domain": "agora", "name":"api", "version":"0.1.10+cds.101", "manifest":{"description":"Sample app"}}' 31 | headers: 32 | assertions: 33 | - result.statuscode ShouldEqual 201 34 | - type: http 35 | method: GET 36 | url: "{{.appsroute}}/agora/api/latest" 37 | headers: 38 | assertions: 39 | - result.statuscode ShouldEqual 200 40 | - type: http 41 | method: GET 42 | url: "{{.appsroute}}/agora/api/latest" 43 | headers: 44 | assertions: 45 | - result.statuscode ShouldEqual 200 46 | - result.bodyjson.version ShouldEqual 0.1.10+cds.101 47 | -------------------------------------------------------------------------------- /webui/src/app/services/data-badgestats.service.ts: -------------------------------------------------------------------------------- 1 | import {throwError as observableThrowError, Observable } from 'rxjs'; 2 | import { Injectable } from '@angular/core'; 3 | import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 4 | import { ConfigurationService } from './configuration.service'; 5 | import { DefaultResource } from '../interfaces/default-resources.interface'; 6 | import { DataCoreResource } from './data-core-resources.service'; 7 | 8 | /** 9 | * data model 10 | */ 11 | 12 | 13 | @Injectable() 14 | export class DataBadgeStatsService{ 15 | private http 16 | private headers 17 | private configuration 18 | private actionUrl 19 | constructor( 20 | private _http: HttpClient, 21 | private _configuration: ConfigurationService 22 | ) { 23 | this.http = _http; 24 | this.configuration = _configuration; 25 | this.headers = new HttpHeaders(); 26 | this.headers.append('Content-Type', 'application/json'); 27 | this.headers.append('Accept', 'application/json'); 28 | this.headers.append('AuthToken', this.configuration.getAuthToken()); 29 | this.actionUrl = _configuration.ApiUrl + 'v1/badges/' 30 | } 31 | 32 | /** 33 | * get all badge values 34 | */ 35 | public GetBadgeStats = (id: string): Observable> => { 36 | this.headers.set('AuthToken', this.configuration.getAuthToken()); 37 | return this.http.get(this.actionUrl + id + '/stats' , {headers: this.headers}) 38 | .catch(this.handleError); 39 | } 40 | 41 | 42 | /** 43 | * error handler 44 | */ 45 | protected handleError(error: HttpErrorResponse) { 46 | return observableThrowError(error || 'Server error'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-nav-bar/oui-nav-bar.component.html: -------------------------------------------------------------------------------- 1 | 28 | 29 | -------------------------------------------------------------------------------- /webui/src/app/services/data-badgeratings.service.ts: -------------------------------------------------------------------------------- 1 | import {throwError as observableThrowError, Observable } from 'rxjs'; 2 | import { Injectable } from '@angular/core'; 3 | import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; 4 | import { ConfigurationService } from './configuration.service'; 5 | import { DefaultResource } from '../interfaces/default-resources.interface'; 6 | import { DataCoreResource } from './data-core-resources.service'; 7 | import { BadgeRatingBean } from '../models/commons/applications-bean'; 8 | 9 | /** 10 | * data model 11 | */ 12 | 13 | 14 | @Injectable() 15 | export class DataBadgeRatingsService{ 16 | private http 17 | private headers 18 | private configuration 19 | private actionUrl 20 | constructor( 21 | private _http: HttpClient, 22 | private _configuration: ConfigurationService 23 | ) { 24 | this.http = _http; 25 | this.configuration = _configuration; 26 | this.headers = new HttpHeaders(); 27 | this.headers.append('Content-Type', 'application/json'); 28 | this.headers.append('Accept', 'application/json'); 29 | this.headers.append('AuthToken', this.configuration.getAuthToken()); 30 | this.actionUrl = _configuration.ApiUrl + 'v1/applications' 31 | } 32 | 33 | /** 34 | * get all badge values 35 | */ 36 | public GetBadgeRatings = (id: string): Observable => { 37 | this.headers.set('AuthToken', this.configuration.getAuthToken()); 38 | return this.http.get(this.actionUrl + '/' + id, {headers: this.headers}) 39 | .catch(this.handleError); 40 | } 41 | 42 | 43 | /** 44 | * error handler 45 | */ 46 | protected handleError(error: HttpErrorResponse) { 47 | return observableThrowError(error || 'Server error'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/handlers/handlers_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/gavv/httpexpect" 8 | "github.com/ovh/lhasa/api/tests" 9 | ) 10 | 11 | func TestAPIRoot(t *testing.T) { 12 | server := tests.StartTestHTTPServer() 13 | defer server.Close() 14 | e := httpexpect.New(t, server.URL) 15 | jsonObj := e.GET("/api"). 16 | Expect(). 17 | Status(http.StatusOK). 18 | JSON().Object() 19 | 20 | jsonObj.Keys().ContainsOnly("_links") 21 | linksArray := jsonObj.Value("_links").Array() 22 | 23 | version1 := linksArray.Element(0).Object() 24 | version1.Value("href").String().Equal("/api/v1") 25 | version1.Value("rel").String().Equal("v1") 26 | 27 | unsecured := linksArray.Element(1).Object() 28 | unsecured.Value("href").String().Equal("/api/unsecured") 29 | unsecured.Value("rel").String().Equal("unsecured") 30 | } 31 | 32 | func TestPing(t *testing.T) { 33 | server := tests.StartTestHTTPServer() 34 | defer server.Close() 35 | 36 | e := httpexpect.New(t, server.URL) 37 | 38 | e.GET("/api/unsecured/mon"). 39 | Expect(). 40 | Status(http.StatusOK). 41 | Body().Equal("\"OK\"") 42 | } 43 | 44 | func TestVersion(t *testing.T) { 45 | server := tests.StartTestHTTPServer() 46 | defer server.Close() 47 | 48 | e := httpexpect.New(t, server.URL) 49 | 50 | e.GET("/api/unsecured/version"). 51 | Expect(). 52 | Status(http.StatusOK). 53 | Body().Equal("\"1.0.0\"") 54 | } 55 | 56 | func TestOpenAPI(t *testing.T) { 57 | server := tests.StartTestHTTPServer() 58 | defer server.Close() 59 | 60 | e := httpexpect.New(t, server.URL) 61 | 62 | e.GET("/api/unsecured/openapi.json"). 63 | Expect(). 64 | Status(http.StatusOK).JSON().Object() 65 | //TODO check some stuff in the openapi json 66 | 67 | e.GET("/api/unsecured/openapi.yaml"). 68 | Expect(). 69 | Status(http.StatusOK) 70 | } 71 | -------------------------------------------------------------------------------- /webui/src/app/services/data-stream-resources.service.ts: -------------------------------------------------------------------------------- 1 | 2 | import {catchError} from 'rxjs/operators'; 3 | 4 | import {throwError as observableThrowError, Observable } from 'rxjs'; 5 | 6 | import { DefaultStreamResource } from '../interfaces/default-resources.interface'; 7 | import { ConfigurationService } from '../services/configuration.service'; 8 | /** 9 | * data model 10 | */ 11 | import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; 12 | 13 | /** 14 | * default class to handle default behaviour for streaml resource 15 | * component 16 | */ 17 | export class DataStreamResource implements DefaultStreamResource { 18 | 19 | protected actionUrl: string; 20 | protected headers: HttpHeaders; 21 | protected http: HttpClient; 22 | protected configuration: ConfigurationService; 23 | 24 | /** 25 | * constructor 26 | */ 27 | constructor(_configuration: ConfigurationService, actionUrl: string, _http: HttpClient) { 28 | this.http = _http; 29 | this.actionUrl = actionUrl; 30 | this.configuration = _configuration; 31 | 32 | this.headers = new HttpHeaders(); 33 | this.headers.append('Content-Type', 'text/plain'); 34 | this.headers.append('Accept', 'text/plain'); 35 | this.headers.append('AuthToken', this.configuration.getAuthToken()); 36 | } 37 | 38 | /** 39 | * get single resource 40 | */ 41 | public GetSingle = (id: string): Observable => { 42 | this.headers.set('AuthToken', this.configuration.getAuthToken()); 43 | return this.http.get(this.actionUrl + '/' + id, {headers: this.headers, responseType: 'text'}).pipe( 44 | catchError(this.handleError)); 45 | } 46 | 47 | /** 48 | * error handler 49 | */ 50 | protected handleError(error: HttpErrorResponse) { 51 | return observableThrowError(error || 'Server error'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /webui/src/assets/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "DOMAINS": "Domains", 3 | "APPLICATIONS": "Applications", 4 | "APPLICATION": "Application", 5 | "MAPS": "Map", 6 | "DOMAINS-ALL": "Domains (all)", 7 | "APPLICATIONS-ALL": "Applications (all)", 8 | "MAPS-ALL": "Maps (all)", 9 | "GRAPHS": "Graphs", 10 | "SEARCH": "Search for {{target}}", 11 | "FILERED_BY_DOMAIN": "Filtered by domain {{target}}", 12 | "DESCRIPTION-MANIFEST": "Description", 13 | "UPDATE-MANIFEST": "Update your app's manifest", 14 | "SAVE-MANIFEST": "Add the manifest to your app's repository", 15 | "DESCRIPTION": "Description", 16 | "SUPPORT": "Support", 17 | "TEAM-NAME": "Team Name", 18 | "TEAM-EMAIL": "Team Email", 19 | "TEAM-CISCO": "Team Cisco", 20 | "CONTRIBUTORS": "Contributors", 21 | "NAME": "Name", 22 | "ROLE": "Role", 23 | "EMAIL": "Email", 24 | "SAVE": "Save", 25 | "BADGES": "Badges", 26 | "LEVELS": "Levels", 27 | "ERROR-APP-DETAILS": "Error while loading application details", 28 | "ERROR-APPLICATIONS": "Error while loading applications", 29 | "ERROR-DOMAINS": "Error while loading domains", 30 | "ERROR-BADGES": "Error while loading badges", 31 | "ERROR-GRAPHS": "Error while loading graphs", 32 | "ERROR-ENVS": "Error while loading environements", 33 | "OPENAPI_SPEC": "OpenAPI Specification", 34 | "REPOSITORY": "Repository", 35 | "CONTACTS": "Contacts", 36 | "LINKS": "Links", 37 | "NETWORK": "Network", 38 | "TAGS": "Tags", 39 | "YOUR_SEARCH_QUERY_RETURNED_0_RESULTS": "Your search query returned 0 results", 40 | "NO_TAGS_SPECIFIED": "No tags specified", 41 | "NO_SOURCE_CODE_REPOSITORY_SPECIFIED": "No source code repository specified", 42 | "ACTIVE_DEPLOYMENTS": "Active Deployments", 43 | "NO_OPENAPI_API_URL_SPECIFIED": "No OpenAPI URL specified", 44 | "INTERNAL_ID": "Internal ID", 45 | "CREATED_AT": "Created At", 46 | "UPDATED_AT": "Updated At" 47 | } 48 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ARCH = $(shell uname | tr '[:upper:]' '[:lower:]') 2 | TARGET_DIR = ./dist 3 | COMPOSE_BIN = $(TARGET_DIR)/docker-compose 4 | API_BIN = $(TARGET_DIR)/appcatalog 5 | VENOM_BIN = $(TARGET_DIR)/venom.$(ARCH) 6 | UI_BASE_HREF ?= / 7 | export UI_BASE_HREF 8 | 9 | all: api webui 10 | 11 | $(TARGET_DIR): 12 | $(info Creating $(TARGET_DIR) directory) 13 | @mkdir -p $(TARGET_DIR) 14 | 15 | $(VENOM_BIN): $(TARGET_DIR) 16 | $(info Installing venom... for $(ARCH)) 17 | curl -L -o $(VENOM_BIN) https://github.com/ovh/venom/releases/download/v0.17.0/venom.$(ARCH)-amd64 18 | @chmod +x $(VENOM_BIN) 19 | 20 | $(COMPOSE_BIN): $(TARGET_DIR) 21 | $(info Installing docker-compose...) 22 | @curl -L https://github.com/docker/compose/releases/download/1.17.0/docker-compose-`uname -s`-`uname -m` -o $(COMPOSE_BIN) 23 | @chmod +x $(COMPOSE_BIN) 24 | 25 | $(API_BIN): 26 | $(MAKE) -C api server 27 | 28 | api: 29 | $(MAKE) -C api 30 | cp scripts/appcatalog.sh dist 31 | cp samples/mycompany.sh dist 32 | 33 | webui: 34 | $(MAKE) -C webui 35 | 36 | test: 37 | $(MAKE) -C api test 38 | $(MAKE) -C webui test 39 | 40 | run: all 41 | cd dist && ./appcatalog --config=../.config.json --auto-migrate 42 | 43 | clean: 44 | $(MAKE) -C api clean 45 | $(MAKE) -C webui clean 46 | 47 | integration-test: $(COMPOSE_BIN) $(VENOM_BIN) $(API_BIN) 48 | $(COMPOSE_BIN) up -d 49 | sleep 10; 50 | { ./${API_BIN} ${DEBUG} --auto-migrate & }; \ 51 | pid=$$!; \ 52 | sleep 5; \ 53 | APP_HOST=http://localhost:8081 $(VENOM_BIN) run --strict --output-dir=$(TARGET_DIR) tests/; \ 54 | r=$$?; \ 55 | kill $$pid; \ 56 | ./${API_BIN} ${DEBUG} migrate down; \ 57 | $(COMPOSE_BIN) down; \ 58 | exit $$r 59 | 60 | sample-test: $(VENOM_BIN) $(API_BIN) 61 | APP_HOST=http://localhost:8081 $(VENOM_BIN) run --log debug --format xml --output-dir=$(TARGET_DIR) tests/*.yml && cat $(TARGET_DIR)/test_results.xml 62 | 63 | .PHONY: all test run clean integration-test api webui 64 | -------------------------------------------------------------------------------- /api/v1/content/handlers.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/loopfz/gadgeto/tonic" 8 | "github.com/ovh/lhasa/api/hateoas" 9 | "github.com/ovh/lhasa/api/v1" 10 | ) 11 | 12 | // HandlerGet returns the first resource matching path params 13 | func HandlerGet(repository *Repository) gin.HandlerFunc { 14 | return tonic.Handler(func(c *gin.Context, content *v1.Content) (interface{}, error) { 15 | if len(content.Locale) == 0 { 16 | content.Locale = "en-GB" 17 | } 18 | result, err := repository.FindOneByUnscoped(map[string]interface{}{"name": content.Name, "locale": content.Locale}) 19 | if hateoas.IsEntityDoesNotExistError(err) { 20 | if err := repository.Save(content); err != nil { 21 | return nil, err 22 | } 23 | return nil, hateoas.EntityDoesNotExistError{ 24 | Criteria: map[string]interface{}{"name": content.Name, "locale": content.Locale}, 25 | EntityName: "Content", 26 | } 27 | } 28 | 29 | return result, nil 30 | }, http.StatusOK) 31 | } 32 | 33 | // HandlerCreate replace or create a resource 34 | func HandlerCreate(repository *Repository) gin.HandlerFunc { 35 | return tonic.Handler(func(c *gin.Context, content *v1.Content) error { 36 | oldres, err := repository.FindOneByUnscoped(map[string]interface{}{"name": content.Name, "locale": content.Locale}) 37 | oldapp := oldres.(*v1.Content) 38 | if hateoas.IsEntityDoesNotExistError(err) { 39 | if err := repository.Save(content); err != nil { 40 | return err 41 | } 42 | return hateoas.ErrorCreated 43 | } 44 | if err != nil { 45 | return err 46 | } 47 | 48 | content.ID = oldapp.ID 49 | content.CreatedAt = oldapp.CreatedAt 50 | if err := repository.Save(content); err != nil { 51 | return err 52 | } 53 | if oldapp.DeletedAt != nil { 54 | return hateoas.ErrorCreated 55 | } 56 | c.Data(http.StatusOK, content.ContentType, content.Body) 57 | return nil 58 | 59 | }, http.StatusOK) 60 | } 61 | -------------------------------------------------------------------------------- /migrations/v0007.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Down 2 | 3 | DROP INDEX IF EXISTS "idx_applications_domain_name"; 4 | 5 | DROP TABLE IF EXISTS "applications"; 6 | 7 | ALTER TABLE IF EXISTS "releases" 8 | RENAME TO "applications"; 9 | 10 | ALTER INDEX IF EXISTS "idx_releases_domain_name_version" 11 | RENAME TO "idx_applications_domain_name_version"; 12 | 13 | ALTER INDEX IF EXISTS "idx_releases_tags" 14 | RENAME TO "idx_applications_tags"; 15 | 16 | -- +migrate Up 17 | 18 | ALTER TABLE IF EXISTS "applications" 19 | RENAME TO "releases"; 20 | 21 | ALTER INDEX IF EXISTS "idx_applications_domain_name_version" 22 | RENAME TO "idx_releases_domain_name_version"; 23 | 24 | ALTER INDEX IF EXISTS "idx_applications_tags" 25 | RENAME TO "idx_releases_tags"; 26 | 27 | CREATE TABLE IF NOT EXISTS "applications" ( 28 | "id" BIGSERIAL, 29 | "domain" VARCHAR(255) NOT NULL DEFAULT '', 30 | "name" VARCHAR(255) NOT NULL DEFAULT '', 31 | "latest_release_id" BIGINT NULL, 32 | "created_at" TIMESTAMP WITH TIME ZONE, 33 | "updated_at" TIMESTAMP WITH TIME ZONE, 34 | "deleted_at" TIMESTAMP WITH TIME ZONE, 35 | PRIMARY KEY ("id"), 36 | FOREIGN KEY (latest_release_id) REFERENCES "releases" ON DELETE SET NULL 37 | ); 38 | 39 | -- IF NOT EXISTS is not compatible with postgres 9.4 40 | CREATE UNIQUE INDEX "idx_applications_domain_name" 41 | ON "applications" ("domain", "name"); 42 | 43 | -- data migrations 44 | 45 | INSERT INTO "applications" ( 46 | "domain", 47 | "name", 48 | latest_release_id, 49 | "created_at", 50 | "updated_at" 51 | ) 52 | SELECT 53 | "v"."domain", 54 | "v"."name", 55 | max("v"."id"), 56 | now(), 57 | now() 58 | FROM "releases" as "v" 59 | WHERE "v"."deleted_at" IS NULL 60 | AND NOT EXISTS(SELECT 1 61 | FROM "applications" as "a" 62 | WHERE ("a"."domain", "a"."name") = ("v"."domain", "v"."name")) 63 | GROUP BY "domain", "name"; 64 | -------------------------------------------------------------------------------- /tests/40-contents-v1.yml: -------------------------------------------------------------------------------- 1 | name: Deployments Endpoint TestSuite 2 | vars: 3 | baseroute: '{{.APP_HOST}}/api/v1' 4 | contsroute: '{{.baseroute}}/contents' 5 | testcases: 6 | - name: Reset the Database 7 | steps: 8 | - type: http 9 | method: DELETE 10 | url: '{{.contsroute}}' 11 | headers: 12 | assertions: 13 | - result.statuscode ShouldEqual 204 14 | 15 | - name: ContentLifecycle 16 | steps: 17 | - type: http 18 | method: PUT 19 | url: "{{.contsroute}}/just-a-test/fr-FR" 20 | body: | 21 | ## Just a tiny markdown 22 | for testing FR 23 | headers: 24 | assertions: 25 | - result.statuscode ShouldEqual 201 26 | - type: http 27 | method: GET 28 | url: "{{.contsroute}}/just-a-test/fr-FR" 29 | headers: 30 | assertions: 31 | - result.body ShouldContainSubstring "## Just a tiny markdown" 32 | - result.body ShouldContainSubstring "for testing FR" 33 | - type: http 34 | method: PUT 35 | url: "{{.contsroute}}/just-a-test/en-GB" 36 | body: | 37 | ## Just a tiny markdown 38 | for testing GB 39 | headers: 40 | assertions: 41 | - result.statuscode ShouldEqual 201 42 | - type: http 43 | method: PUT 44 | url: "{{.contsroute}}/just-a-test/en-US" 45 | body: | 46 | ## Just a tiny markdown 47 | for testing US 48 | headers: 49 | assertions: 50 | - result.statuscode ShouldEqual 201 51 | - type: http 52 | method: GET 53 | url: "{{.contsroute}}/just-a-test/en-US" 54 | headers: 55 | assertions: 56 | - result.body ShouldContainSubstring "## Just a tiny markdown" 57 | - result.body ShouldContainSubstring "for testing US" 58 | - type: http 59 | method: GET 60 | url: "{{.contsroute}}/just-a-test" 61 | headers: 62 | assertions: 63 | - result.body ShouldContainSubstring "## Just a tiny markdown" 64 | - result.body ShouldContainSubstring "for testing GB" 65 | 66 | -------------------------------------------------------------------------------- /webui/src/app/components/appdetail/appdetail.component.css: -------------------------------------------------------------------------------- 1 | .spacer { 2 | height: 10em; 3 | } 4 | 5 | .deploymenubutton input { 6 | display: none; 7 | } 8 | 9 | input[type="radio"]+label { 10 | display: block; 11 | border-top: 0.1em solid lightgray; 12 | border-bottom: 0.1em solid lightgray; 13 | border-radius: 0.5em 0 0 0.5em; 14 | padding: 1em; 15 | } 16 | 17 | input[type="radio"]:checked+label { 18 | background-color: rgb(17, 40, 68); 19 | } 20 | 21 | .deploymentmenu{ 22 | padding: 0; 23 | width: 25%; 24 | float: left; 25 | } 26 | 27 | .deploymentpanel{ 28 | border-top: 0.1em solid lightgray; 29 | border-bottom: 0.1em solid lightgray; 30 | background-color: rgb(17, 40, 68); 31 | padding: 2em; 32 | width: 75%; 33 | float: left; 34 | border-radius: 0 0.5em 0.5em 0; 35 | } 36 | 37 | .deploymentmenu .oui-icon-chevron-right { 38 | float: right; 39 | } 40 | 41 | .deploymentmenu .oui-chip { 42 | margin-right: 1em; 43 | float: right; 44 | } 45 | 46 | .intro { 47 | color: white; 48 | } 49 | 50 | .intro a { 51 | color: white; 52 | } 53 | 54 | .intro dt { 55 | font-weight: bolder; 56 | } 57 | 58 | .deploymentscontainer { 59 | display: flex; 60 | flex-wrap: nowrap; 61 | } 62 | 63 | .font-extra-light{ 64 | color: #b3becc; 65 | } 66 | 67 | .authorlist{ 68 | font-size: smaller; 69 | } 70 | 71 | .nobullets { 72 | list-style-type: none; 73 | margin: 0; 74 | padding: 0; 75 | } 76 | 77 | .link_label { 78 | color: floralwhite; 79 | background-color: #54627b; 80 | border-radius: 15px 0px 0px 15px; 81 | width: 10em; 82 | display: inline-block; 83 | padding-left: 0.5em; 84 | font-size: smaller; 85 | font-family: sans-serif; 86 | } 87 | .link_url { 88 | background-color: aliceblue; 89 | border-radius: 0px 15px 15px 0px; 90 | display: inline-block; 91 | padding-left: 0.5em; 92 | padding-right: 0.5em; 93 | font-size: smaller; 94 | font-family: sans-serif; 95 | } 96 | 97 | .link_url .oui-link { 98 | color: dimgray; 99 | } 100 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-nav-bar/oui-nav-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Output, EventEmitter, Input, ElementRef, OnDestroy, Renderer2 } from '@angular/core'; 2 | 3 | import { ActivatedRoute } from '@angular/router'; 4 | import { ApplicationBean } from '../../models/commons/applications-bean'; 5 | import { Store } from '@ngrx/store'; 6 | import { ApplicationsStoreService } from '../../stores/applications-store.service'; 7 | import { UiKitMenuItem } from '../../models/kit/navbar'; 8 | import { DomHandler } from 'primeng/primeng'; 9 | import { each } from 'lodash'; 10 | import { HelpsStoreService } from '../../stores/help-store.service'; 11 | 12 | @Component({ 13 | selector: 'app-oui-nav-bar', 14 | templateUrl: './oui-nav-bar.component.html', 15 | styleUrls: ['./oui-nav-bar.component.css'], 16 | providers: [DomHandler] 17 | }) 18 | export class OuiNavBarComponent implements OnInit { 19 | 20 | visible = true; 21 | @Input() home = '/'; 22 | @Input() items: UiKitMenuItem; 23 | 24 | @Output() select: EventEmitter = new EventEmitter(); 25 | 26 | constructor( 27 | private el: ElementRef, 28 | private domHandler: DomHandler, 29 | private renderer: Renderer2, 30 | public help: HelpsStoreService 31 | ) { 32 | } 33 | 34 | ngOnInit() { 35 | } 36 | 37 | onSelect(event: any, tabs: string) { 38 | event.data = tabs; 39 | this.select.emit(event); 40 | } 41 | 42 | release() { 43 | each(this.items, (item) => { 44 | each(item.items, (subitem) => { 45 | each(DomHandler.find(this.el.nativeElement, '#button-' + subitem.id), (elem) => { 46 | elem.expanded = false; 47 | elem.setAttribute('aria-expanded', 'false'); 48 | }); 49 | }); 50 | each(DomHandler.find(this.el.nativeElement, '#button-' + item.id), (elem) => { 51 | elem.expanded = false; 52 | elem.setAttribute('aria-expanded', 'false'); 53 | }); 54 | }); 55 | } 56 | 57 | expand(item: UiKitMenuItem) { 58 | item.expanded = !item.expanded; 59 | const elem = DomHandler.findSingle(this.el.nativeElement, '#button-' + item.id); 60 | elem.setAttribute('aria-expanded', String(item.expanded)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /webui/src/app/services/data-graph-resources.service.ts: -------------------------------------------------------------------------------- 1 | import { catchError } from 'rxjs/operators'; 2 | 3 | import { throwError as observableThrowError, Observable } from 'rxjs'; 4 | 5 | import { DefaultResource, DefaultGraphResource } from '../interfaces/default-resources.interface'; 6 | import { ConfigurationService } from '../services/configuration.service'; 7 | /** 8 | * data model 9 | */ 10 | import { ContentListResponse, EntityBean } from '../models/commons/entity-bean'; 11 | import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; 12 | 13 | /** 14 | * default class to handle default behaviour or resource 15 | * component 16 | */ 17 | export class DataGraphResource implements DefaultGraphResource { 18 | 19 | protected actionUrl: string; 20 | protected headers: HttpHeaders; 21 | protected http: HttpClient; 22 | protected configuration: ConfigurationService; 23 | 24 | /** 25 | * constructor 26 | */ 27 | constructor(_configuration: ConfigurationService, actionUrl: string, _http: HttpClient) { 28 | this.http = _http; 29 | this.actionUrl = actionUrl; 30 | this.configuration = _configuration; 31 | 32 | this.headers = new HttpHeaders(); 33 | this.headers.append('Content-Type', 'application/json'); 34 | this.headers.append('Accept', 'application/json'); 35 | this.headers.append('AuthToken', this.configuration.getAuthToken()); 36 | } 37 | 38 | /** 39 | * get single resource 40 | */ 41 | public Get = (params: any): Observable => { 42 | this.headers.set('AuthToken', this.configuration.getAuthToken()); 43 | return this.http.get(this.actionUrl, {headers: this.headers}).pipe( 44 | catchError(this.handleError)); 45 | }; 46 | 47 | public GetDeployment = (id: string): Observable => { 48 | this.headers.set('AuthToken', this.configuration.getAuthToken()); 49 | return this.http.get(`${this.actionUrl}/${id}`, {headers: this.headers}).pipe( 50 | catchError(this.handleError) 51 | ); 52 | }; 53 | 54 | /** 55 | * error handler 56 | */ 57 | protected handleError(error: HttpErrorResponse) { 58 | return observableThrowError(error || 'Server error'); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-message/oui-message.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Output, EventEmitter, Input, ElementRef, OnDestroy, Renderer2 } from '@angular/core'; 2 | 3 | import { ActivatedRoute } from '@angular/router'; 4 | import { ApplicationBean } from '../../models/commons/applications-bean'; 5 | import { Store } from '@ngrx/store'; 6 | import { ApplicationsStoreService } from '../../stores/applications-store.service'; 7 | import { UiKitMenuItem } from '../../models/kit/navbar'; 8 | import { DomHandler } from 'primeng/primeng'; 9 | 10 | @Component({ 11 | selector: 'app-oui-message', 12 | templateUrl: './oui-message.component.html', 13 | styleUrls: [], 14 | providers: [DomHandler] 15 | }) 16 | export class OuiMessageComponent implements OnInit { 17 | 18 | _message: String; 19 | _stack: any; 20 | _type: String; 21 | _klass: String; 22 | public visible = true; 23 | 24 | @Output() select: EventEmitter = new EventEmitter(); 25 | 26 | constructor( 27 | ) { 28 | this._type = 'info'; 29 | this.validate(); 30 | } 31 | 32 | ngOnInit() { 33 | } 34 | 35 | @Input() get message(): String { 36 | return this._message; 37 | } 38 | 39 | @Input() get type(): String { 40 | return this._type; 41 | } 42 | 43 | @Input() get stack(): any { 44 | return this._stack; 45 | } 46 | 47 | /** 48 | * hide 49 | */ 50 | public hide() { 51 | this.visible = false; 52 | } 53 | 54 | /** 55 | * setter 56 | */ 57 | set message(val: String) { 58 | this._message = val; 59 | } 60 | set type(val: String) { 61 | this._type = val; 62 | this.validate(); 63 | } 64 | set stack(val: any) { 65 | this._stack = val; 66 | } 67 | 68 | validate() { 69 | switch(this._type) { 70 | case 'info': 71 | this._klass = 'oui-message oui-message_info'; 72 | break; 73 | case 'error': 74 | this._klass = 'oui-message oui-message_error'; 75 | break; 76 | } 77 | } 78 | 79 | /** 80 | * emit selection 81 | * @param event 82 | * @param type 83 | * @param page 84 | */ 85 | onSelect(event: any, type: string, page: string) { 86 | event.data = { 87 | sender: this, 88 | message: this.message, 89 | type: type, 90 | }; 91 | this.select.emit(event); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /webui/src/app/resolver/resolve-graph.ts: -------------------------------------------------------------------------------- 1 | import { Observable , BehaviorSubject , Subject } from 'rxjs'; 2 | import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Injectable } from '@angular/core'; 4 | import { LoadersStoreService } from '../stores/loader-store.service'; 5 | import { GraphBean } from '../models/graph/graph-bean'; 6 | import { DataGraphService } from '../services/data-graph.service'; 7 | import { GraphsStoreService, LoadGraphDeploymentAction } from '../stores/graphs-store.service'; 8 | import { ErrorsStoreService, ErrorBean, NewErrorAction } from '../stores/errors-store.service'; 9 | 10 | @Injectable() 11 | export class GraphsResolver implements Resolve { 12 | constructor( 13 | private dataGraphService: DataGraphService, 14 | private graphsStoreService: GraphsStoreService, 15 | private errorsStoreService: ErrorsStoreService, 16 | private loadersStoreService: LoadersStoreService, 17 | ) { } 18 | 19 | resolve( 20 | route: ActivatedRouteSnapshot, 21 | state: RouterStateSnapshot 22 | ): Observable | Promise | any { 23 | // Only one graph at this moment, deployment graph 24 | // Soon may be we have to see route params to select the graph 25 | return this.selectGraphDeployment({}, new BehaviorSubject('select one graph with query')); 26 | } 27 | 28 | /** 29 | * dispatch load Graphs 30 | * @param event 31 | */ 32 | public selectGraphDeployment(params: any, subject: Subject): Subject { 33 | this.loadersStoreService.notify(subject); 34 | // load all Graphs 35 | this.dataGraphService.Get(params).subscribe( 36 | (data: GraphBean) => { 37 | this.graphsStoreService.dispatch( 38 | new LoadGraphDeploymentAction(data, subject) 39 | ); 40 | }, 41 | (error) => { 42 | this.errorsStoreService.dispatch(new NewErrorAction( 43 | { 44 | code: 'ERROR-GRAPHS', 45 | stack: JSON.stringify(error, null, 2), 46 | }, subject 47 | )); 48 | } 49 | ); 50 | return subject; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/20-environments-v1.yml: -------------------------------------------------------------------------------- 1 | name: Environments Endpoint TestSuite 2 | vars: 3 | baseroute: '{{.APP_HOST}}/api/v1' 4 | envsroute: '{{.baseroute}}/environments' 5 | testcases: 6 | - name: Reset the Database 7 | steps: 8 | - type: http 9 | method: DELETE 10 | url: '{{.envsroute}}' 11 | headers: 12 | assertions: 13 | - result.statuscode ShouldEqual 204 14 | 15 | - name: EnvironmentLifecycle 16 | steps: 17 | - type: http 18 | method: PUT 19 | url: "{{.envsroute}}/prodca" 20 | body: '{"name": "Prod CA", "properties": {"role": "production", "region": "CA"}}' 21 | assertions: 22 | - result.statuscode ShouldEqual 201 23 | - type: http 24 | method: GET 25 | url: "{{.envsroute}}/prodca" 26 | headers: 27 | assertions: 28 | - result.bodyjson.name ShouldEqual "Prod CA" 29 | - result.bodyjson.properties.role ShouldEqual production 30 | - result.bodyjson.properties.region ShouldEqual CA 31 | - type: http 32 | method: PUT 33 | url: "{{.envsroute}}/prodca" 34 | headers: 35 | body: '{"name": "Prod CA", "properties": {"role": "production", "region": "CA", "owner": "me"}}' 36 | assertions: 37 | - result.statuscode ShouldEqual 200 38 | - type: http 39 | method: GET 40 | url: "{{.envsroute}}/prodca" 41 | headers: 42 | assertions: 43 | - result.bodyjson.name ShouldEqual "Prod CA" 44 | - result.bodyjson.properties.role ShouldEqual production 45 | - result.bodyjson.properties.region ShouldEqual CA 46 | - result.bodyjson.properties.owner ShouldEqual me 47 | - type: http 48 | method: GET 49 | url: "{{.envsroute}}" 50 | headers: 51 | assertions: 52 | - result.statuscode ShouldEqual 206 53 | - result.bodyjson.pagemetadata.totalelements ShouldEqual 1 54 | - result.bodyjson.pagemetadata.totalpages ShouldEqual 1 55 | - result.bodyjson.pagemetadata.number ShouldEqual 0 56 | - type: http 57 | method: DELETE 58 | url: "{{.envsroute}}/prodca" 59 | headers: 60 | assertions: 61 | - result.statuscode ShouldEqual 204 62 | - type: http 63 | method: DELETE 64 | url: "{{.envsroute}}/prodca" 65 | headers: 66 | assertions: 67 | - result.statuscode ShouldEqual 410 68 | -------------------------------------------------------------------------------- /api/security/policy_test.go: -------------------------------------------------------------------------------- 1 | package security_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/gobwas/glob" 8 | "github.com/ovh/lhasa/api/security" 9 | ) 10 | 11 | func TestBuildRolePolicy(t *testing.T) { 12 | data := []struct { 13 | policy security.RolePolicy 14 | roles []security.Role 15 | noRoles []security.Role 16 | }{ 17 | { 18 | policy: security.BuildRolePolicy( 19 | security.Policy{ 20 | "ROLE_ADMIN": { 21 | "X-Remote-User": {"john.doe"}, 22 | }, 23 | "ROLE_USER": { 24 | "X-Remote-User": {glob.MustCompile("*")}, 25 | }, 26 | }, 27 | &http.Request{Header: map[string][]string{"X-Remote-User": {"john.doe"}}}), 28 | roles: []security.Role{security.RoleAdmin, security.RoleUser}, 29 | }, 30 | { 31 | policy: security.BuildRolePolicy( 32 | security.Policy{ 33 | "ROLE_ADMIN": { 34 | "X-Remote-User": {"john.doe"}, 35 | }, 36 | "ROLE_USER": { 37 | "X-Remote-User": {glob.MustCompile("*")}, 38 | }, 39 | }, 40 | &http.Request{Header: map[string][]string{"X-Remote-User": {"foo.bar"}}}), 41 | roles: []security.Role{security.RoleUser}, 42 | noRoles: []security.Role{security.RoleAdmin}, 43 | }, 44 | { 45 | policy: security.BuildRolePolicy( 46 | security.Policy{ 47 | "ROLE_ADMIN": { 48 | "X-Ovh-Gateway-Source": {"foobar"}, 49 | }, 50 | "ROLE_USER": { 51 | "X-Remote-User": {glob.MustCompile("*")}, 52 | }, 53 | }, 54 | &http.Request{Header: map[string][]string{"X-Remote-User": {"foo.bar"}}}), 55 | roles: []security.Role{security.RoleUser}, 56 | noRoles: []security.Role{security.RoleAdmin}, 57 | }, 58 | { 59 | policy: security.BuildRolePolicy( 60 | security.Policy{ 61 | "ROLE_ADMIN": { 62 | "X-Ovh-Gateway-Source": {"foobar"}, 63 | }, 64 | }, 65 | &http.Request{Header: map[string][]string{"X-Ovh-Gateway-Source": {"foobar"}}}), 66 | roles: []security.Role{security.RoleAdmin}, 67 | noRoles: []security.Role{security.RoleUser}, 68 | }, 69 | } 70 | 71 | for i, run := range data { 72 | if !run.policy.HasAll(run.roles...) { 73 | t.Errorf("Test %d - Expected %v - Found %v", i+1, run.roles, run.policy.ToSlice()) 74 | } 75 | if run.policy.HasOne(run.noRoles...) { 76 | t.Errorf("Test %d - Expected %v - Found %v", i+1, run.roles, run.policy.ToSlice()) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /api/v1/application/latest.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/coreos/go-semver/semver" 5 | "github.com/jinzhu/gorm" 6 | "github.com/ovh/lhasa/api/db" 7 | "github.com/ovh/lhasa/api/hateoas" 8 | "github.com/ovh/lhasa/api/v1" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // LatestUpdater updates the application latest version to the given version 13 | type LatestUpdater func(*v1.Release, logrus.FieldLogger) error 14 | 15 | // NewLatestUpdater instantiates a LatestUpdater 16 | func NewLatestUpdater(tm db.TransactionManager, appRepoFactory RepositoryFactory, latestRepoFactory RepositoryLatestFactory) LatestUpdater { 17 | return func(version *v1.Release, log logrus.FieldLogger) error { 18 | return tm.Transaction(func(db *gorm.DB) error { 19 | appRepo := appRepoFactory(db) 20 | latestRepo := latestRepoFactory(db) 21 | 22 | log := log.WithFields(logrus.Fields{ 23 | "domain": version.Domain, 24 | "name": version.Name, 25 | "version": version.Version, 26 | }) 27 | application, err := latestRepo.FindApplication(version.Domain, version.Name) 28 | if err != nil && !hateoas.IsEntityDoesNotExistError(err) { 29 | return err 30 | } 31 | if hateoas.IsEntityDoesNotExistError(err) || application.LatestReleaseID == nil { 32 | log.Debug("application doesn't exist or doesn't have a latest so it will be created to the given version") 33 | application = &v1.Application{ 34 | ID: application.ID, 35 | Domain: version.Domain, 36 | Name: version.Name, 37 | } 38 | } 39 | 40 | if err := appRepo.Save(version); err != nil { 41 | return err 42 | } 43 | if shouldUpdate(application.LatestRelease, version, log) { 44 | application.LatestRelease = version 45 | return latestRepo.Save(application) 46 | } 47 | return nil 48 | }, log) 49 | } 50 | } 51 | 52 | func shouldUpdate(current, submitted *v1.Release, log *logrus.Entry) bool { 53 | if current == nil { 54 | return true 55 | } 56 | if submitted == nil { 57 | return false 58 | } 59 | submittedSemver, err := semver.NewVersion(submitted.Version) 60 | if err != nil { 61 | log.Infof("version '%s' is not semver compliant, so it wont be used as latest", submitted.Version) 62 | return false 63 | } 64 | currentSemver, err := semver.NewVersion(current.Version) 65 | if err != nil { 66 | return true 67 | } 68 | return currentSemver.Compare(*submittedSemver) <= 0 69 | } 70 | -------------------------------------------------------------------------------- /api/Makefile: -------------------------------------------------------------------------------- 1 | OUT := ../dist/appcatalog 2 | PKG := github.com/ovh/lhasa/api 3 | VERSION := $(shell git describe --always --tags --dirty) 4 | PKG_LIST := $(shell go list ${PKG}/... | grep -v /vendor/) 5 | PKG_LIST_COMMA := $(shell go list ${PKG}/... | grep -v /vendor/ | paste -s -d, -) 6 | PACKR = ${GOPATH}/bin/packr 7 | GO_META_LINTER = ${GOPATH}/bin/gometalinter 8 | GO_COV_MERGE = ${GOPATH}/bin/gocovmerge 9 | GO_GOVERALLS = ${GOPATH}/bin/goveralls 10 | GO_GO2XUNIT = ${GOPATH}/bin/go2xunit 11 | GO_DEP = ${GOPATH}/bin/dep 12 | TARGET_DIR = ../dist 13 | 14 | all: server 15 | 16 | $(TARGET_DIR): 17 | $(info create $(TARGET_DIR) directory) 18 | @mkdir -p $(TARGET_DIR) 19 | 20 | $(GO_META_LINTER): 21 | go get -u gopkg.in/alecthomas/gometalinter.v2 22 | 23 | $(GO_COV_MERGE): 24 | go get -u github.com/wadey/gocovmerge 25 | 26 | $(GO_GO2XUNIT): 27 | go get -u github.com/tebeka/go2xunit 28 | 29 | $(GO_DEP): 30 | go get -u github.com/golang/dep/cmd/dep 31 | 32 | $(PACKR): 33 | go get -u github.com/gobuffalo/packr/packr 34 | 35 | install: $(GO_DEP) $(PACKR) 36 | dep ensure 37 | packr 38 | 39 | server: vet lint install 40 | packr 41 | go build -i -o ${OUT} -ldflags="-X main.version=${VERSION}" ${PKG}/cmd/appcatalog 42 | 43 | test: install 44 | @go test ./... 45 | 46 | unused: 47 | codecoroner -ignore vendor funcs ./... 48 | 49 | test-coverage: 50 | @for pkg in ${PKG_LIST}; do go test --coverprofile $(TARGET_DIR)/$${pkg//\//-}.single.cov $${pkg}; done; 51 | @for pkg in ${PKG_LIST}; do go test --coverprofile $(TARGET_DIR)/$${pkg//\//-}.global.cov --coverpkg=${PKG_LIST_COMMA} $${pkg}; done; 52 | @$(GO_COV_MERGE) $(TARGET_DIR)/*.global.cov > $(TARGET_DIR)/global.cov 53 | @go tool cover -html=$(TARGET_DIR)/global.cov -o=$(TARGET_DIR)/cover.html 54 | @go tool cover -func=$(TARGET_DIR)/global.cov 55 | 56 | vet: install 57 | @go vet ${PKG_LIST} 58 | 59 | lint: $(GO_META_LINTER) install 60 | -gometalinter.v2 --install 61 | gometalinter.v2 ./... 62 | 63 | static: vet lint 64 | go build -i -v -o ${OUT}-v${VERSION} -tags netgo -ldflags="-extldflags \"-static\" -w -s -X main.version=${VERSION}" ${PKG}/app 65 | 66 | run: server 67 | ./${OUT} 68 | 69 | live: 70 | APPCATALOG_CONFIG_FILE=../.config.json APPCATALOG_DEBUG_MODE=true LHASA_WEB_UI_DIR=../dist/webui gin -p 8081 -a 3000 -i --path . --build cmd/appcatalog -- --port=3000 71 | 72 | clean: 73 | @packr clean 74 | -@rm ${OUT} ${OUT}-v* 75 | 76 | .PHONY: run server static vet lint ensure integration-test 77 | -------------------------------------------------------------------------------- /webui/src/app/stores/environments-store.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { createFeatureSelector, createSelector, MemoizedSelector, Store } from '@ngrx/store'; 3 | 4 | import { ActionWithPayload } from './action-with-payload'; 5 | import { EnvironmentBean } from '../models/commons/applications-bean'; 6 | import { Observable } from 'rxjs'; 7 | 8 | /** 9 | * states 10 | */ 11 | export interface EnvironmentState { 12 | environments: Map; 13 | } 14 | 15 | /** 16 | * actions 17 | */ 18 | export class LoadEnvironmentsAction implements ActionWithPayload> { 19 | readonly type = 'LoadEnvironmentsAction'; 20 | 21 | constructor(public payload: Map) { 22 | } 23 | } 24 | 25 | export type AllStoreActions = LoadEnvironmentsAction; 26 | 27 | /** 28 | * main store for this application 29 | */ 30 | @Injectable() 31 | export class EnvironmentsStoreService { 32 | 33 | private getAll: MemoizedSelector>; 34 | 35 | /** 36 | * metareducer (Cf. https://www.concretepage.com/angular-2/ngrx/ngrx-store-4-angular-5-tutorial) 37 | * @param state 38 | * @param action 39 | */ 40 | public static reducer(state: EnvironmentState = { 41 | environments: new Map(), 42 | }, action: AllStoreActions): EnvironmentState { 43 | 44 | switch (action.type) { 45 | /** 46 | * message incomming 47 | */ 48 | case 'LoadEnvironmentsAction': { 49 | const environments = Object.assign(new Map(), action.payload); 50 | return { 51 | environments: environments, 52 | }; 53 | } 54 | 55 | default: 56 | return state; 57 | } 58 | } 59 | 60 | /** 61 | * 62 | * @param _store constructor 63 | */ 64 | constructor( 65 | private _store: Store 66 | ) { 67 | this.getAll = createSelector(createFeatureSelector('environments'), (state: EnvironmentState) => state.environments); 68 | } 69 | 70 | /** 71 | * select this store service 72 | */ 73 | public environments(): Observable> { 74 | return this._store.select(this.getAll); 75 | } 76 | 77 | /** 78 | * dispatch 79 | * @param action dispatch action 80 | */ 81 | public dispatch(action: AllStoreActions) { 82 | this._store.dispatch(action); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /api/v1/domain/repository.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/juju/errors" 8 | "github.com/ovh/lhasa/api/hateoas" 9 | "github.com/ovh/lhasa/api/v1" 10 | ) 11 | 12 | const ( 13 | defaultPageSize = 20 14 | ) 15 | 16 | // Repository is a repository manager for domains 17 | type Repository struct { 18 | db *gorm.DB 19 | } 20 | 21 | // NewRepository creates an application repository 22 | func NewRepository(db *gorm.DB) *Repository { 23 | return &Repository{ 24 | db: db, 25 | } 26 | } 27 | 28 | // GetType returns the entity type managed by this repository 29 | func (repo *Repository) GetType() reflect.Type { 30 | return reflect.TypeOf(v1.Domain{}) 31 | } 32 | 33 | // FindPageBy returns a page of matching entities 34 | func (repo *Repository) FindPageBy(pageable hateoas.Pageable, criteria map[string]interface{}) (hateoas.Page, error) { 35 | page := hateoas.NewPage(pageable, defaultPageSize, v1.DomainBasePath) 36 | var domains []*v1.Domain 37 | if err := repo.db.Model(&v1.Release{}). 38 | Where(criteria). 39 | Order(page.Pageable.GetGormSortClause()). 40 | Limit(page.Pageable.Size). 41 | Offset(page.Pageable.GetOffset()). 42 | Select("\"releases\".\"domain\" as \"name\", count(*) as app_count"). 43 | Where("\"releases\".\"deleted_at\" is null"). 44 | Group("\"releases\".\"domain\""). 45 | Scan(&domains).Error; err != nil { 46 | return page, err 47 | } 48 | page.Content = domains 49 | 50 | count := 0 51 | rows, err := repo.db.Raw("select count(distinct \"releases\".\"domain\") \"totalElements\" from \"releases\" where \"releases\".\"deleted_at\" is null").Rows() 52 | if err != nil { 53 | return page, err 54 | } 55 | defer func() { 56 | _ = rows.Close() 57 | }() 58 | if rows.Next() { 59 | if err := rows.Scan(&count); err != nil { 60 | return page, err 61 | } 62 | page.TotalElements = count 63 | } 64 | return page, nil 65 | } 66 | 67 | // FindBy fetch a collection of domains matching each criteria 68 | func (repo *Repository) FindBy(criteria map[string]interface{}) (interface{}, error) { 69 | return nil, errors.NotSupportedf("operation not supported") 70 | } 71 | 72 | // FindOneBy find by criteria 73 | func (repo *Repository) FindOneBy(criteria map[string]interface{}) (hateoas.Entity, error) { 74 | var app v1.Release 75 | err := repo.db.First(&app, criteria).Error 76 | if gorm.IsRecordNotFoundError(err) { 77 | return &app, hateoas.NewEntityDoesNotExistError(app, criteria) 78 | } 79 | if err != nil { 80 | return nil, err 81 | } 82 | return &v1.Domain{Name: app.Domain}, nil 83 | } 84 | -------------------------------------------------------------------------------- /api/graphapi/type.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | // ImplementsGraph defines and identifiable node entity 8 | type ImplementsGraph interface { 9 | } 10 | 11 | // ImplementNode define an entity which can have dependencies 12 | type ImplementNode interface { 13 | GetID() string 14 | GetName() string 15 | GetType() string 16 | GetEdges() []ImplementEdge 17 | } 18 | 19 | // ImplementEdge defines and identifiable node entity 20 | type ImplementEdge interface { 21 | GetID() string 22 | GetFrom() string 23 | GetTo() string 24 | GetType() string 25 | GetProperties() interface{} 26 | } 27 | 28 | // Repository defines a normal repository 29 | type Repository interface { 30 | FindAll() (*Graph, error) 31 | FindAllActive(map[string]interface{}) (*Graph, error) 32 | GetType() reflect.Type 33 | // Map api entity to graph entity 34 | Map(entity interface{}, others map[string]ImplementNode) ImplementNode 35 | // Resolve entity dependencies 36 | Resolve(entity interface{}, mappers map[string]ImplementNode) 37 | } 38 | 39 | // Graph define a graph resource 40 | type Graph struct { 41 | Nodes []Node `json:"nodes"` 42 | Edges []Edge `json:"edges"` 43 | } 44 | 45 | // Node define a node resource 46 | type Node struct { 47 | ID string `json:"id"` 48 | Name string `json:"name"` 49 | Type string `json:"type"` 50 | Properties interface{} `json:"properties"` 51 | } 52 | 53 | // GetID get source entity 54 | func (n *Node) GetID() string { 55 | return n.ID 56 | } 57 | 58 | // GetName get name 59 | func (n *Node) GetName() string { 60 | return n.Name 61 | } 62 | 63 | // GetType get name 64 | func (n *Node) GetType() string { 65 | return n.Type 66 | } 67 | 68 | // Edge define a edge resource 69 | type Edge struct { 70 | ID string `json:"id"` 71 | From string `json:"from"` 72 | To string `json:"to"` 73 | Type string `json:"type"` 74 | Properties interface{} `json:"properties"` 75 | } 76 | 77 | // GetID get edge entity 78 | func (e *Edge) GetID() string { 79 | return e.ID 80 | } 81 | 82 | // GetFrom get edge entity 83 | func (e *Edge) GetFrom() string { 84 | return e.From 85 | } 86 | 87 | // GetTo get edge entity 88 | func (e *Edge) GetTo() string { 89 | return e.To 90 | } 91 | 92 | // GetType get edge type 93 | func (e *Edge) GetType() string { 94 | return e.Type 95 | } 96 | 97 | // GetProperties get edge entity 98 | func (e *Edge) GetProperties() interface{} { 99 | return e.Properties 100 | } 101 | -------------------------------------------------------------------------------- /api/handlers/hooks.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/gin-gonic/gin/binding" 11 | "github.com/juju/errors" 12 | "github.com/loopfz/gadgeto/tonic" 13 | ) 14 | 15 | var ( 16 | // binary Simple binary binding 17 | binary binaryBinding 18 | // whitelist Simple content white list 19 | whitelist = []string{"text/plain", "application/json"} 20 | ) 21 | 22 | // MediaResource defines a media resource behaviour 23 | type MediaResource interface { 24 | GetContentType() string 25 | GetBytes() []byte 26 | SetBytes([]byte) 27 | } 28 | 29 | type binaryBinding struct{} 30 | 31 | // Name returns "binary" as a name for this binding 32 | func (binaryBinding) Name() string { 33 | return "binary" 34 | } 35 | 36 | // Bind perform this binary binding 37 | func (binaryBinding) Bind(req *http.Request, obj interface{}) error { 38 | // Scan interface implementation 39 | media, ok := obj.(MediaResource) 40 | if !ok { 41 | return nil 42 | } 43 | // Check white list 44 | for _, value := range req.Header["Content-Type"] { 45 | if !stringInSlice(value, whitelist) { 46 | return fmt.Errorf("unsupported content-type %s", value) 47 | } 48 | } 49 | 50 | buf, err := ioutil.ReadAll(req.Body) 51 | if err != nil { 52 | return err 53 | } 54 | media.SetBytes(buf) 55 | return nil 56 | } 57 | 58 | func stringInSlice(a string, list []string) bool { 59 | for _, b := range list { 60 | if b == a { 61 | return true 62 | } 63 | } 64 | return false 65 | } 66 | 67 | // BindHook hook for lhasa, override default one 68 | func BindHook(c *gin.Context, i interface{}) error { 69 | c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, tonic.MaxBodyBytes) 70 | if c.Request.ContentLength == 0 || c.Request.Method == http.MethodGet { 71 | return nil 72 | } 73 | defaultBindError := errors.New("error parsing request body") 74 | if err := c.ShouldBindWith(i, binary); err != nil && err != io.EOF { 75 | return errors.Wrap(err, defaultBindError) 76 | } 77 | if err := c.ShouldBindWith(i, binding.JSON); err != nil && err != io.EOF { 78 | return errors.Wrap(err, defaultBindError) 79 | } 80 | return nil 81 | } 82 | 83 | // RenderHookWrapper hook for lhasa, override default one 84 | func RenderHookWrapper(hook tonic.RenderHook) tonic.RenderHook { 85 | return func(c *gin.Context, status int, payload interface{}) { 86 | // Scan interface implementation 87 | media, ok := payload.(MediaResource) 88 | if ok { 89 | c.Data(http.StatusOK, media.GetContentType(), media.GetBytes()) 90 | return 91 | } 92 | if hook == nil { 93 | return 94 | } 95 | hook(c, status, payload) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /webui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "console", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve --aot --host localhost --no-live-reload", 8 | "build": "ng build --prod", 9 | "test": "ng test", 10 | "lint": "ng lint", 11 | "e2e": "ng e2e" 12 | }, 13 | "private": true, 14 | "dependencies": { 15 | "@angular/animations": "^7.2.7", 16 | "@angular/cdk": "^7.3.3", 17 | "@angular/common": "^7.2.7", 18 | "@angular/compiler": "^7.2.7", 19 | "@angular/core": "^7.2.7", 20 | "@angular/forms": "^7.2.7", 21 | "@angular/http": "^7.2.7", 22 | "@angular/material": "^7.3.3", 23 | "@angular/platform-browser": "^7.2.7", 24 | "@angular/platform-browser-dynamic": "^7.2.7", 25 | "@angular/router": "^7.2.7", 26 | "@ngrx/core": "^1.2.0", 27 | "@ngrx/store": "^7.3.0", 28 | "@ngx-translate/core": "^11.0.1", 29 | "@ngx-translate/http-loader": "^4.0.0", 30 | "@types/hammerjs": "^2.0.36", 31 | "angular-wizard": "^1.1.1", 32 | "buffer": "^5.2.1", 33 | "chart.js": "^2.7.3", 34 | "core-js": "^2.6.5", 35 | "cytoscape": "^3.4.2", 36 | "cytoscape-cose-bilkent": "^4.0.0", 37 | "cytoscape-expand-collapse": "^3.1.2", 38 | "extend": "^3.0.2", 39 | "font-awesome": "^4.7.0", 40 | "hammerjs": "^2.0.8", 41 | "jquery": "^3.3.1", 42 | "lodash": "^4.17.11", 43 | "marked": "^0.6.1", 44 | "ng2-archwizard": "^2.1.0", 45 | "ngx-cytoscape": "^0.5.24", 46 | "ngx-md": "^7.1.3", 47 | "ovh-ui-kit": "^2.25.0", 48 | "primeng": "^7.1.0-rc.1", 49 | "prismjs": "^1.15.0", 50 | "quill": "^1.3.6", 51 | "rxjs": "^6.4.0", 52 | "rxjs-compat": "^6.4.0", 53 | "rxjs-tslint": "^0.1.7", 54 | "stream": "0.0.2", 55 | "swagger-ui": "^3.21.0", 56 | "vis": "~4.21.0", 57 | "zone.js": "^0.8.29" 58 | }, 59 | "devDependencies": { 60 | "@angular-devkit/build-angular": "^0.13.4", 61 | "@angular/cli": "^7.3.4", 62 | "@angular/compiler-cli": "^7.2.7", 63 | "@angular/language-service": "^7.2.7", 64 | "@types/chart.js": "^2.7.45", 65 | "@types/jasmine": "^3.3.9", 66 | "@types/jasminewd2": "^2.0.6", 67 | "@types/node": "^11.10.4", 68 | "codelyzer": "^4.5.0", 69 | "jasmine-core": "^3.3.0", 70 | "jasmine-spec-reporter": "~4.2.1", 71 | "karma": "^4.0.1", 72 | "karma-chrome-launcher": "~2.2.0", 73 | "karma-cli": "~2.0.0", 74 | "karma-coverage-istanbul-reporter": "^2.0.5", 75 | "karma-jasmine": "^2.0.1", 76 | "karma-jasmine-html-reporter": "^1.4.0", 77 | "less": "^3.9.0", 78 | "protractor": "^5.4.2", 79 | "ts-node": "^8.0.2", 80 | "tslint": "^5.13.1", 81 | "typescript": "^3.2.4" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /webui/src/app/models/commons/applications-bean.ts: -------------------------------------------------------------------------------- 1 | import { EntityBean, PageMetaData, HrefLinks, AbstractPaginatedResource } from './entity-bean'; 2 | import { Timestamp } from 'rxjs'; 3 | import { BadgeLevelBean } from './badges-bean'; 4 | import { GraphBean, GraphVis } from '../graph/graph-bean'; 5 | 6 | 7 | // Application 8 | export class ApplicationBean extends EntityBean { 9 | domain: string; 10 | name: string; 11 | type?: string; 12 | language?: string; 13 | project?: string; 14 | repo?: string; 15 | description?: string; 16 | manifest: ManifestBean; 17 | properties: ReleasePropertiesBean; 18 | tags?: string[]; 19 | deployments?: DeploymentBean[]; 20 | badgeRatings?: BadgeRatingBean[]; 21 | version: string; 22 | } 23 | 24 | // Manifest 25 | export class ManifestBean { 26 | name?: string; 27 | profile?: string; 28 | description?: string; 29 | repository?: string; 30 | authors?: PersonBean[]; 31 | support?: TeamBean; 32 | } 33 | 34 | // ReleasePropertiesBean 35 | export class ReleasePropertiesBean { 36 | description: string; 37 | readme: string; 38 | links: Map; 39 | } 40 | 41 | // Deployment 42 | export class DeploymentBean extends EntityBean { 43 | id: string; 44 | properties: DeploymentPropertiesBean = new DeploymentPropertiesBean(); 45 | undeployedAt: Date; 46 | _graph: GraphVis; 47 | } 48 | 49 | // DeploymentPropertiesBean 50 | export class DeploymentPropertiesBean { 51 | links: Map = new Map([]); 52 | } 53 | 54 | // Environment 55 | export class EnvironmentBean extends EntityBean { 56 | slug: string; 57 | name: string; 58 | properties: Map; 59 | } 60 | 61 | // Person 62 | export class PersonBean { 63 | name: string; 64 | email: string; 65 | role: string; 66 | cisco: string; 67 | } 68 | 69 | // Team 70 | export class TeamBean { 71 | name: string; 72 | email: string; 73 | cisco: string; 74 | } 75 | 76 | // Domain 77 | export class DomainBean extends EntityBean { 78 | name: string; 79 | applications?: ApplicationBean[]; 80 | _links?: HrefLinks[]; 81 | } 82 | 83 | // Domain for page browse 84 | export class ApplicationPagesBean extends AbstractPaginatedResource { 85 | applications: ApplicationBean[] = []; 86 | } 87 | 88 | // Domain for page browse 89 | export class DomainPagesBean extends AbstractPaginatedResource { 90 | domains: DomainBean[] = []; 91 | } 92 | 93 | // BadgeRatingBean for a gamification badge value for an application version 94 | export class BadgeRatingBean { 95 | badgeslug: string; 96 | badgetitle: string; 97 | value: string; 98 | comment: string; 99 | level: BadgeLevelBean; 100 | } 101 | -------------------------------------------------------------------------------- /webui/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { GraphBrowseComponent } from './components/graphs/graph-browse.component'; 2 | import { environment } from '../environments/environment'; 3 | import { NgModule } from '@angular/core'; 4 | import { Routes, RouterModule } from '@angular/router'; 5 | 6 | import { ApplicationsComponent } from './components/applications/applications.component'; 7 | import { AppdetailComponent } from './components/appdetail/appdetail.component'; 8 | import { ProfileGuard } from './guards/profile.guard'; 9 | import { AppEditComponent } from './components/appedit/appedit.component'; 10 | import { RoutingGuard } from './guards/routing.guard'; 11 | import { ApplicationResolver } from './resolver/resolve-app-detail'; 12 | import { DomainsBrowseComponent } from './components/domains-browse/domains-browse.component'; 13 | import { DomainsResolver } from './resolver/resolve-domains'; 14 | import { ApplicationsResolver } from './resolver/resolve-applications'; 15 | import { GraphsResolver } from './resolver/resolve-graph'; 16 | import { BadgesComponent } from './components/badges/badges.component'; 17 | import { BadgesResolver } from './resolver/resolve-badges'; 18 | 19 | 20 | const routes: Routes = [ 21 | { 22 | path: '', 23 | redirectTo: 'applications', 24 | pathMatch: 'full' 25 | }, 26 | { 27 | path: 'graph/deployments', 28 | resolve: { 29 | graph: GraphsResolver 30 | }, 31 | component: GraphBrowseComponent, 32 | canActivate: [ProfileGuard, RoutingGuard] 33 | }, 34 | { 35 | path: 'applications', 36 | resolve: { 37 | applications: ApplicationsResolver 38 | }, 39 | component: ApplicationsComponent, 40 | canActivate: [ProfileGuard, RoutingGuard] 41 | }, 42 | { 43 | path: 'applications/:domain/:name', 44 | resolve: { 45 | application: ApplicationResolver 46 | }, 47 | component: AppdetailComponent, 48 | canActivate: [ProfileGuard, RoutingGuard] 49 | }, 50 | { 51 | path: 'applications/:domain/:name/versions/:version/edit', 52 | resolve: { 53 | application: ApplicationResolver 54 | }, 55 | component: AppEditComponent, 56 | canActivate: [ProfileGuard, RoutingGuard] 57 | }, 58 | { 59 | path: 'domains', 60 | resolve: { 61 | domains: DomainsResolver 62 | }, 63 | component: DomainsBrowseComponent, 64 | canActivate: [ProfileGuard, RoutingGuard] 65 | }, 66 | { 67 | path: 'badges', 68 | resolve: { 69 | badges: BadgesResolver 70 | }, 71 | component: BadgesComponent, 72 | canActivate: [ProfileGuard, RoutingGuard] 73 | }, 74 | ]; 75 | 76 | @NgModule({ 77 | imports: [RouterModule.forRoot(routes, { enableTracing: environment.tracing })], 78 | exports: [RouterModule] 79 | }) 80 | export class AppRoutingModule { } 81 | -------------------------------------------------------------------------------- /api/v1/deployment/deployer.go: -------------------------------------------------------------------------------- 1 | package deployment 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/juju/errors" 8 | "github.com/ovh/lhasa/api/db" 9 | "github.com/ovh/lhasa/api/v1" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // Deployer deploys an application version to the given environment and removes old deployments 14 | type Deployer func(application v1.Release, environment v1.Environment, deployment *v1.Deployment, log logrus.FieldLogger) (*v1.Deployment, bool, error) 15 | 16 | // ApplicationDeployer deploys an application version to the given environment and removes old deployments 17 | func ApplicationDeployer(tm db.TransactionManager, depFactory RepositoryFactory) Deployer { 18 | return func(app v1.Release, env v1.Environment, dep *v1.Deployment, log logrus.FieldLogger) (*v1.Deployment, bool, error) { 19 | c := false 20 | created := &c 21 | var d **v1.Deployment 22 | err := tm.Transaction(func(db *gorm.DB) error { 23 | depRepo := depFactory(db) 24 | j, err := getProperties(dep, app.Domain, app.Name, app.Version, env.Slug) 25 | if err != nil { 26 | return err 27 | } 28 | if err := dep.Properties.UnmarshalJSON(j); err != nil { 29 | return err 30 | } 31 | dep.EnvironmentID = env.ID 32 | dep.ApplicationID = app.ID 33 | 34 | // Looking for a previous deployment on the same release / environment 35 | prevs, err := depRepo.FindActivesByRelease(app.Domain, app.Name, app.Version, map[string]interface{}{"environment_id": env.ID}) 36 | if err != nil { 37 | log. 38 | WithField("domain", app.Domain). 39 | WithField("name", app.Name). 40 | WithField("version", app.Version). 41 | WithField("env", env.Slug). 42 | WithError(err). 43 | Warnf("was not able to find previous active deployment for this release") 44 | } 45 | if len(prevs) > 0 { 46 | prev := prevs[0] 47 | if err := prev.Properties.UnmarshalJSON(j); err != nil { 48 | return err 49 | } 50 | d = &prev 51 | return depRepo.Save(prev) 52 | } 53 | 54 | if err := depRepo.UndeployByApplicationEnv(app.Domain, app.Name, env.ID); err != nil { 55 | return err 56 | } 57 | 58 | c := true 59 | created = &c 60 | d = &dep 61 | return depRepo.Save(dep) 62 | }, log) 63 | return *d, *created, err 64 | } 65 | } 66 | 67 | func getProperties(dep *v1.Deployment, domain, name, version, envSlug string) ([]byte, error) { 68 | props := map[string]interface{}{} 69 | if len(dep.Properties.RawMessage) > 0 { 70 | if err := json.Unmarshal(dep.Properties.RawMessage, &props); err != nil { 71 | return nil, errors.BadRequestf("properties field should be a valid json object: %s", err.Error()) 72 | } 73 | } 74 | props["_app_domain"] = domain 75 | props["_app_name"] = name 76 | props["_app_version"] = version 77 | props["_env_slug"] = envSlug 78 | return json.Marshal(props) 79 | } 80 | -------------------------------------------------------------------------------- /webui/src/app/resolver/resolve-domains.ts: -------------------------------------------------------------------------------- 1 | import { Observable , BehaviorSubject , Subject } from 'rxjs'; 2 | import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Injectable } from '@angular/core'; 4 | import { DomainBean, DomainPagesBean } from '../models/commons/applications-bean'; 5 | import { LoadDomainsAction, ApplicationsStoreService } from '../stores/applications-store.service'; 6 | import { DataApplicationService } from '../services/data-application-version.service'; 7 | import { ContentListResponse, PageMetaData } from '../models/commons/entity-bean'; 8 | import { DataDeploymentService } from '../services/data-deployment.service'; 9 | import { DataDomainService } from '../services/data-domain.service'; 10 | import { LoadersStoreService } from '../stores/loader-store.service'; 11 | import { ErrorsStoreService, NewErrorAction, ErrorBean } from '../stores/errors-store.service'; 12 | 13 | @Injectable() 14 | export class DomainsResolver implements Resolve { 15 | constructor( 16 | private applicationsStoreService: ApplicationsStoreService, 17 | private domainsService: DataDomainService, 18 | private loadersStoreService: LoadersStoreService, 19 | private errorsStoreService: ErrorsStoreService, 20 | ) { } 21 | 22 | resolve( 23 | route: ActivatedRouteSnapshot, 24 | state: RouterStateSnapshot 25 | ): Observable | Promise | any { 26 | return this.selectDomains({ 27 | number: route.queryParams.page || 0, 28 | size: 50 29 | }, new BehaviorSubject('select all domains')); 30 | } 31 | 32 | /** 33 | * dispatch load domains 34 | * @param event 35 | */ 36 | public selectDomains(metadata: PageMetaData, subject: Subject): Subject { 37 | this.loadersStoreService.notify(subject); 38 | // load all domains 39 | const meta: { 40 | [key: string]: any | any[]; 41 | } = { 42 | size: metadata.size, 43 | page: metadata.number 44 | }; 45 | this.domainsService.GetAllFromContent('', meta).subscribe( 46 | (data: ContentListResponse) => { 47 | this.applicationsStoreService.dispatch( 48 | new LoadDomainsAction({ 49 | domains: data.content, 50 | metadata: data.pageMetadata, 51 | }, subject) 52 | ); 53 | }, 54 | (error) => { 55 | this.errorsStoreService.dispatch(new NewErrorAction( 56 | { 57 | code: 'ERROR-DOMAINS', 58 | stack: JSON.stringify(error, null, 2), 59 | }, subject 60 | )); 61 | } 62 | ); 63 | return subject; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /api/db/dbconfig.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math/rand" 7 | "strings" 8 | "time" 9 | 10 | "github.com/jinzhu/gorm" 11 | _ "github.com/jinzhu/gorm/dialects/postgres" // This is required by GORM to enable postgresql support 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | const ( 16 | maxOpenConns = 10 17 | maxIdleConns = 3 18 | ) 19 | 20 | // NewDBHandle provides the database handle to its callers 21 | func NewDBHandle(dc DatabaseCredentials, logMode bool, log logrus.FieldLogger) (*gorm.DB, error) { 22 | connStr, err := dc.GetRW() 23 | if err != nil { 24 | return nil, err 25 | } 26 | return NewFromGormString(connStr, logMode, log) 27 | } 28 | 29 | // NewFromGormString creates a gorm db handler from a connection string 30 | func NewFromGormString(connStr string, logMode bool, log logrus.FieldLogger) (*gorm.DB, error) { 31 | db, err := gorm.Open("postgres", connStr) 32 | if err != nil { 33 | return nil, err 34 | } 35 | db.DB().SetMaxIdleConns(maxIdleConns) 36 | db.DB().SetMaxOpenConns(maxOpenConns) 37 | db.LogMode(logMode) 38 | db.SetLogger(getLogger(log)) 39 | return db, nil 40 | } 41 | 42 | // GetRW get a read/write database 43 | func (dc *DatabaseCredentials) GetRW() (string, error) { 44 | return dc.getConnStr(dc.Writers) 45 | } 46 | 47 | // GetRO get a read only database 48 | func (dc *DatabaseCredentials) GetRO() (string, error) { 49 | return dc.getConnStr(dc.Readers) 50 | } 51 | 52 | func (dc *DatabaseCredentials) getConnStr(instances []DatabaseInstance) (string, error) { 53 | dbType, err := dc.getType() 54 | if err != nil { 55 | return "", err 56 | } 57 | i, err := getRandom(instances) 58 | if err != nil { 59 | return "", err 60 | } 61 | return buildConnStr(dbType, dc, i), nil 62 | } 63 | 64 | func (dc *DatabaseCredentials) getType() (Type, error) { 65 | switch strings.ToLower(dc.Type) { 66 | case "postgresql": 67 | return PostgreSQL, nil 68 | } 69 | return "", fmt.Errorf("unsupported DB type '%s'", dc.Type) 70 | } 71 | 72 | func (dc *DatabaseCredentials) getSslDefaultMode(value string) (string, error) { 73 | if len(value) > 0 { 74 | return value, nil 75 | } 76 | switch strings.ToLower(dc.Type) { 77 | case "postgresql": 78 | return PostgreSQLDefaultSslMode, nil 79 | } 80 | return "", fmt.Errorf("unsupported DB type '%s'", dc.Type) 81 | } 82 | 83 | func getRandom(instances []DatabaseInstance) (*DatabaseInstance, error) { 84 | max := len(instances) 85 | if max == 0 { 86 | return nil, errors.New("no suitable db instance found") 87 | } 88 | rand.Seed(time.Now().Unix()) 89 | return &instances[rand.Intn(max)], nil 90 | } 91 | 92 | func buildConnStr(fmtStr Type, dc *DatabaseCredentials, i *DatabaseInstance) string { 93 | // build sslmode with default value according do bdd type 94 | var sslmode, _ = dc.getSslDefaultMode(i.Ssl) 95 | return fmt.Sprintf(string(fmtStr), dc.User, dc.Password, i.Host, i.Port, dc.Database, sslmode) 96 | } 97 | -------------------------------------------------------------------------------- /webui/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | // import 'core-js/es6/symbol'; 23 | // import 'core-js/es6/object'; 24 | // import 'core-js/es6/function'; 25 | // import 'core-js/es6/parse-int'; 26 | // import 'core-js/es6/parse-float'; 27 | // import 'core-js/es6/number'; 28 | // import 'core-js/es6/math'; 29 | // import 'core-js/es6/string'; 30 | // import 'core-js/es6/date'; 31 | // import 'core-js/es6/array'; 32 | // import 'core-js/es6/regexp'; 33 | // import 'core-js/es6/map'; 34 | // import 'core-js/es6/weak-map'; 35 | // import 'core-js/es6/set'; 36 | 37 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 38 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 39 | 40 | /** IE10 and IE11 requires the following for the Reflect API. */ 41 | // import 'core-js/es6/reflect'; 42 | 43 | 44 | /** Evergreen browsers require these. **/ 45 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 46 | import 'core-js/es7/reflect'; 47 | 48 | 49 | /** 50 | * Required to support Web Animations `@angular/platform-browser/animations`. 51 | * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation 52 | **/ 53 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 54 | 55 | 56 | 57 | /*************************************************************************************************** 58 | * Zone JS is required by default for Angular itself. 59 | */ 60 | import 'zone.js/dist/zone'; // Included with Angular CLI. 61 | 62 | 63 | 64 | /*************************************************************************************************** 65 | * APPLICATION IMPORTS 66 | */ 67 | 68 | // required to have SwaggerUI work 69 | // See https://stackoverflow.com/questions/50371593/angular-6-uncaught-referenceerror-buffer-is-not-defined 70 | (window as any).global = window; 71 | global.Buffer = global.Buffer || require('buffer').Buffer; 72 | -------------------------------------------------------------------------------- /api/v1/environment/environment_test.go: -------------------------------------------------------------------------------- 1 | package environment_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | mocket "github.com/Selvatico/go-mocket" 8 | "github.com/gavv/httpexpect" 9 | "github.com/ovh/lhasa/api/tests" 10 | ) 11 | 12 | func TestEnvironmentAdd(t *testing.T) { 13 | server := tests.StartTestHTTPServer() 14 | defer server.Close() 15 | 16 | emptyListReply := []map[string]interface{}{} 17 | mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "environments" WHERE`).WithReply(emptyListReply) 18 | 19 | e := httpexpect.New(t, server.URL) 20 | env := map[string]interface{}{} 21 | 22 | e.PUT("/api/v1/environments/prod"). 23 | WithHeader("Content-Type", "application/json"). 24 | WithJSON(env). 25 | Expect(). 26 | Status(http.StatusCreated) 27 | } 28 | 29 | func TestEnvironmentUpdate(t *testing.T) { 30 | server := tests.StartTestHTTPServer() 31 | defer server.Close() 32 | 33 | emptyListReply := []map[string]interface{}{{"id": 12}} 34 | mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "environments" WHERE`).WithReply(emptyListReply) 35 | 36 | e := httpexpect.New(t, server.URL) 37 | env := map[string]interface{}{} 38 | 39 | e.PUT("/api/v1/environments/prod"). 40 | WithHeader("Content-Type", "application/json"). 41 | WithJSON(env). 42 | Expect(). 43 | Status(http.StatusOK) 44 | } 45 | 46 | func TestEnvironmentDelete(t *testing.T) { 47 | server := tests.StartTestHTTPServer() 48 | defer server.Close() 49 | 50 | listReply := []map[string]interface{}{{"id": 12}} 51 | mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "environments" WHERE`).WithReply(listReply) 52 | 53 | e := httpexpect.New(t, server.URL) 54 | 55 | e.DELETE("/api/v1/environments/prod"). 56 | Expect(). 57 | Status(http.StatusNoContent) 58 | } 59 | 60 | func TestEnvironmentList(t *testing.T) { 61 | server := tests.StartTestHTTPServer() 62 | defer server.Close() 63 | 64 | countReply := []map[string]interface{}{{"count(*)": 2}} 65 | listReply := []map[string]interface{}{{"name": "prod"}, {"name": "staging"}} 66 | 67 | mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "environments" WHERE`).WithReply(listReply) 68 | mocket.Catcher.NewMock().WithQuery(`SELECT count(*) FROM "environments" WHERE "environments"."deleted_at" IS NULL`).WithReply(countReply) 69 | 70 | e := httpexpect.New(t, server.URL) 71 | jsonObj := e.GET("/api/v1/environments/"). 72 | Expect(). 73 | Status(http.StatusPartialContent). 74 | JSON().Object() 75 | 76 | jsonObj.Keys().ContainsOnly("content", "pageMetadata", "_links") 77 | pageMetadata := jsonObj.Value("pageMetadata").Object() 78 | pageMetadata.Keys().ContainsOnly("totalElements", "totalPages", "size", "number") 79 | pageMetadata.ValueEqual("totalElements", 2). 80 | ValueEqual("totalPages", 1). 81 | ValueEqual("size", 20). 82 | ValueEqual("number", 0) 83 | 84 | content := jsonObj.Value("content").Array() 85 | content.Length().Equal(2) 86 | content.Element(0).Object().ValueEqual("name", "prod") 87 | content.Element(1).Object().ValueEqual("name", "staging") 88 | } 89 | -------------------------------------------------------------------------------- /webui/src/app/resolver/resolve-applications.ts: -------------------------------------------------------------------------------- 1 | import { Observable , BehaviorSubject , Subject } from 'rxjs'; 2 | import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Injectable } from '@angular/core'; 4 | import { ApplicationBean, ApplicationPagesBean } from '../models/commons/applications-bean'; 5 | import { LoadDomainsAction, ApplicationsStoreService, LoadApplicationsAction } from '../stores/applications-store.service'; 6 | import { DataApplicationService } from '../services/data-application-version.service'; 7 | import { ContentListResponse, PageMetaData } from '../models/commons/entity-bean'; 8 | import { DataDeploymentService } from '../services/data-deployment.service'; 9 | import { DataDomainService } from '../services/data-domain.service'; 10 | import { LoadersStoreService } from '../stores/loader-store.service'; 11 | import { ErrorsStoreService, ErrorBean, NewErrorAction } from '../stores/errors-store.service'; 12 | import { Params } from '@angular/router/src/shared'; 13 | import { HttpParams } from '@angular/common/http/src/params'; 14 | 15 | @Injectable() 16 | export class ApplicationsResolver implements Resolve { 17 | constructor( 18 | private applicationsStoreService: ApplicationsStoreService, 19 | private applicationsService: DataApplicationService, 20 | private loadersStoreService: LoadersStoreService, 21 | private errorsStoreService: ErrorsStoreService, 22 | ) { 23 | 24 | } 25 | 26 | resolve( 27 | route: ActivatedRouteSnapshot, 28 | state: RouterStateSnapshot 29 | ): Observable | Promise | any { 30 | return this.selectApplications( route.queryParams, new BehaviorSubject('select all applications')); 31 | } 32 | 33 | /** 34 | * dispatch load domains 35 | * @param event 36 | */ 37 | public selectApplications(params: Params, subject: Subject): Subject { 38 | this.loadersStoreService.notify(subject); 39 | const paramsClone = Object.assign({}, params); 40 | paramsClone.sort = 'domain'; 41 | if (paramsClone.size === undefined) { 42 | paramsClone.size = 100; 43 | } 44 | paramsClone.sort = 'domain'; 45 | this.applicationsService.GetAllFromContent('', paramsClone).subscribe( 46 | (data: ContentListResponse) => { 47 | this.applicationsStoreService.dispatch( 48 | new LoadApplicationsAction({ 49 | applications: data.content, 50 | metadata: data.pageMetadata, 51 | }, subject) 52 | ); 53 | }, 54 | (error) => { 55 | this.errorsStoreService.dispatch(new NewErrorAction( 56 | { 57 | code: 'ERROR-APPLICATION', 58 | stack: JSON.stringify(error, null, 2), 59 | }, subject 60 | )); 61 | } 62 | ); 63 | return subject; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /webui/src/app/stores/graphs-store.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { createFeatureSelector, createSelector, Selector, Store } from '@ngrx/store'; 3 | 4 | import { ActionWithPayloadAndPromise } from './action-with-payload'; 5 | import { ApplicationBean, ApplicationPagesBean, DeploymentBean, DomainBean, DomainPagesBean } from '../models/commons/applications-bean'; 6 | import { Subject } from 'rxjs'; 7 | import { GraphBean } from '../models/graph/graph-bean'; 8 | import { Observable } from 'rxjs'; 9 | 10 | /** 11 | * states 12 | */ 13 | export interface GraphsState { 14 | /** 15 | * store each graph with a key 16 | */ 17 | deployments: GraphBean; 18 | } 19 | 20 | /** 21 | * actions 22 | */ 23 | export class LoadGraphDeploymentAction implements ActionWithPayloadAndPromise { 24 | readonly type = LoadGraphDeploymentAction.getType(); 25 | 26 | public static getType(): string { 27 | return 'LoadGraphDeploymentAction'; 28 | } 29 | 30 | constructor(public payload: GraphBean, public subject?: Subject) { 31 | } 32 | } 33 | 34 | export type AllStoreActions = LoadGraphDeploymentAction; 35 | 36 | /** 37 | * main store for this Graph 38 | */ 39 | @Injectable() 40 | export class GraphsStoreService { 41 | 42 | readonly getDeploymentGraph: Selector; 43 | 44 | /** 45 | * 46 | * @param _store constructor 47 | */ 48 | constructor( 49 | private _store: Store 50 | ) { 51 | this.getDeploymentGraph = GraphsStoreService.create((state: GraphsState) => state.deployments); 52 | } 53 | 54 | /** 55 | * create a selector 56 | * @param handler internal static 57 | */ 58 | private static create(handler: (S1: GraphsState) => any) { 59 | return createSelector(createFeatureSelector('graphs'), handler); 60 | } 61 | 62 | /** 63 | * metareducer (Cf. https://www.concretepage.com/angular-2/ngrx/ngrx-store-4-angular-5-tutorial) 64 | * @param state 65 | * @param action 66 | */ 67 | public static reducer(state: GraphsState = { 68 | deployments: new GraphBean(), 69 | }, action: AllStoreActions): GraphsState { 70 | 71 | switch (action.type) { 72 | /** 73 | * update all applications in store 74 | */ 75 | case LoadGraphDeploymentAction.getType(): { 76 | const graph = Object.assign({}, action.payload); 77 | 78 | // Complete load action 79 | action.subject.complete(); 80 | return { 81 | deployments: graph, 82 | }; 83 | } 84 | 85 | 86 | default: 87 | return state; 88 | } 89 | } 90 | 91 | /** 92 | * select this store service 93 | */ 94 | public deployments(): Observable { 95 | return this._store.select(this.getDeploymentGraph); 96 | } 97 | 98 | /** 99 | * dispatch 100 | * @param action dispatch action 101 | */ 102 | public dispatch(action: AllStoreActions) { 103 | this._store.dispatch(action); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-progress-tracker/oui-progress-tracker.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core'; 2 | import { each } from 'lodash'; 3 | 4 | import { ActivatedRoute } from '@angular/router'; 5 | import { ApplicationBean } from '../../models/commons/applications-bean'; 6 | import { Store } from '@ngrx/store'; 7 | import { ApplicationsStoreService } from '../../stores/applications-store.service'; 8 | import { UiKitStep } from '../../models/kit/progress-tracker'; 9 | 10 | @Component({ 11 | selector: 'app-oui-progress-tracker', 12 | templateUrl: './oui-progress-tracker.component.html', 13 | styleUrls: ['./oui-progress-tracker.component.css'] 14 | }) 15 | export class OuiProgressTrackerComponent implements OnInit { 16 | 17 | _steps: UiKitStep[] = []; 18 | current: UiKitStep; 19 | 20 | @Output() select: EventEmitter = new EventEmitter(); 21 | 22 | constructor( 23 | ) { 24 | } 25 | 26 | /** 27 | * setter 28 | */ 29 | @Input() set steps(val: UiKitStep[]) { 30 | this._steps = val; 31 | this.selectStep(this._steps[0]); 32 | } 33 | 34 | ngOnInit() { 35 | } 36 | 37 | onSelect(event: any, step: UiKitStep) { 38 | event.data = step; 39 | this.selectStep(step); 40 | this.select.emit(event); 41 | } 42 | 43 | selectStep(sel: UiKitStep) { 44 | let oneActive = false; 45 | this.current = sel; 46 | this._steps.forEach((step) => { 47 | if (step.id === sel.id) { 48 | step.status = 'active'; 49 | oneActive = true; 50 | } else { 51 | if (oneActive) { 52 | step.status = 'disabled'; 53 | } else { 54 | step.status = 'complete'; 55 | } 56 | } 57 | switch (step.status) { 58 | case 'complete': 59 | step.stepClass = 'oui-progress-tracker__step_complete'; 60 | step.labelClass = 'oui-progress-tracker__label_complete'; 61 | return; 62 | case 'active': 63 | step.stepClass = 'oui-progress-tracker__step_active'; 64 | step.labelClass = 'oui-progress-tracker__label_active'; 65 | return; 66 | case 'disabled': 67 | step.stepClass = 'oui-progress-tracker__step_disabled'; 68 | step.labelClass = 'oui-progress-tracker__label_disabled'; 69 | return; 70 | default: 71 | return; 72 | } 73 | }); 74 | } 75 | 76 | index(find) { 77 | let found = 0; 78 | let index = 0; 79 | each(this._steps, (step) => { 80 | if (step.id === find.id) { 81 | found = index; 82 | } else { 83 | } 84 | index++; 85 | }); 86 | return found; 87 | } 88 | 89 | prev() { 90 | let p = this.index(this.current) - 1; 91 | if (p < 0) { 92 | p = 0; 93 | } 94 | this.onSelect({}, this._steps[p]); 95 | } 96 | 97 | next() { 98 | let n = this.index(this.current) + 1; 99 | if (n >= this._steps.length) { 100 | n = this._steps.length - 1; 101 | } 102 | this.onSelect({}, this._steps[n]); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /webui/src/app/kit/oui-pagination/oui-pagination.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Output, EventEmitter, Input, ElementRef, OnDestroy, Renderer2 } from '@angular/core'; 2 | 3 | import { ActivatedRoute } from '@angular/router'; 4 | import { ApplicationBean } from '../../models/commons/applications-bean'; 5 | import { Store } from '@ngrx/store'; 6 | import { ApplicationsStoreService } from '../../stores/applications-store.service'; 7 | import { UiKitMenuItem } from '../../models/kit/navbar'; 8 | import { DomHandler } from 'primeng/primeng'; 9 | import { each } from 'lodash'; 10 | import { UiKitPaginate } from '../../models/kit/paginate'; 11 | 12 | class LogicalPage { 13 | index: number; 14 | disabled: string; 15 | selected: string; 16 | } 17 | 18 | @Component({ 19 | selector: 'app-oui-pagination', 20 | templateUrl: './oui-pagination.component.html', 21 | styleUrls: ['./oui-pagination.component.css'], 22 | providers: [DomHandler] 23 | }) 24 | export class OuiPaginationComponent implements OnInit { 25 | 26 | _metadata: UiKitPaginate; 27 | pages: LogicalPage[]; 28 | 29 | @Output() select: EventEmitter = new EventEmitter(); 30 | 31 | constructor( 32 | ) { 33 | } 34 | 35 | ngOnInit() { 36 | } 37 | 38 | @Input() get metadata(): UiKitPaginate { 39 | return this._metadata; 40 | } 41 | 42 | /** 43 | * setter 44 | */ 45 | set metadata(val: UiKitPaginate) { 46 | this._metadata = val; 47 | this.update(); 48 | } 49 | 50 | /** 51 | * update style 52 | */ 53 | update() { 54 | this.pages = []; 55 | for (let index = 0; index < this._metadata.totalPages; index++) { 56 | let status = 'disabled'; 57 | let selected = 'oui-button oui-button_primary oui-button_small-width oui-pagination-button_selected'; 58 | if (index !== this._metadata.number) { 59 | status = ''; 60 | selected = 'oui-button oui-button_secondary oui-button_small-width'; 61 | } 62 | this.pages.push({ 63 | index: index, 64 | disabled: status, 65 | selected: selected 66 | }); 67 | } 68 | } 69 | 70 | /** 71 | * emit selection 72 | * @param event 73 | * @param type 74 | * @param page 75 | */ 76 | onSelect(event: any, type: string, page: number) { 77 | event.data = { 78 | page: this.compute(this._metadata, type, page), 79 | metadata: this._metadata 80 | }; 81 | this.select.emit(event); 82 | } 83 | 84 | /** 85 | * refresh metadata 86 | */ 87 | RefreshMetadata(metadata: UiKitPaginate, type: string, page: number) { 88 | const event: any = {}; 89 | this._metadata = metadata; 90 | event.data = { 91 | page: this.compute(this._metadata, type, page), 92 | metadata: this._metadata 93 | }; 94 | this.update(); 95 | this.select.emit(event); 96 | } 97 | 98 | /** 99 | * compute next page 100 | */ 101 | compute(metadata: UiKitPaginate, type: string, page: number): number { 102 | let go = 0; 103 | switch (type) { 104 | case 'next': 105 | go = metadata.number + 1; 106 | break; 107 | case 'previous': 108 | go = metadata.number - 1; 109 | break; 110 | case 'select': 111 | go = Number(page); 112 | break; 113 | } 114 | if (go >= metadata.totalPages) { 115 | go = metadata.totalPages - 1; 116 | } 117 | if (go < 0) { 118 | go = 0; 119 | } 120 | return go; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /webui/src/app/stores/badges-store.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { createFeatureSelector, createSelector, Selector, Store } from '@ngrx/store'; 3 | 4 | import { ActionWithPayloadAndPromise } from './action-with-payload'; 5 | import { BadgeBean, BadgePagesBean } from '../models/commons/badges-bean'; 6 | import { Subject } from 'rxjs'; 7 | import { Observable } from 'rxjs'; 8 | 9 | /** 10 | * states 11 | */ 12 | export interface BadgeState { 13 | /** 14 | * badges of each application loaded in store 15 | */ 16 | badgePages: BadgePagesBean; 17 | /** 18 | * badges of each application loaded in store 19 | */ 20 | badges: Array; 21 | } 22 | 23 | /** 24 | * actions 25 | */ 26 | 27 | 28 | export class LoadBadgesAction implements ActionWithPayloadAndPromise { 29 | readonly type = LoadBadgesAction.getType(); 30 | 31 | public static getType(): string { 32 | return 'LoadBadgesAction'; 33 | } 34 | 35 | constructor(public payload: BadgePagesBean, public subject: Subject) { 36 | } 37 | } 38 | 39 | export type AllStoreActions = LoadBadgesAction ; 40 | 41 | /** 42 | * main store for this application 43 | */ 44 | @Injectable() 45 | export class BadgesStoreService { 46 | 47 | readonly getBadgePages: Selector; 48 | readonly getBadges: Selector>; 49 | 50 | /** 51 | * 52 | * @param _store constructor 53 | */ 54 | constructor( 55 | private _store: Store 56 | ) { 57 | this.getBadges = createSelector(createFeatureSelector('badges'), (state: BadgeState) => state.badges); 58 | this.getBadgePages = BadgesStoreService.create((state: BadgeState) => state.badgePages); 59 | } 60 | 61 | /** 62 | * create a selector 63 | * @param handler internal static 64 | */ 65 | private static create(handler: (S1: BadgeState) => any) { 66 | return createSelector(createFeatureSelector('badges'), handler); 67 | } 68 | 69 | /** 70 | * metareducer (Cf. https://www.concretepage.com/angular-2/ngrx/ngrx-store-4-angular-5-tutorial) 71 | * @param state 72 | * @param action 73 | */ 74 | public static reducer(state: BadgeState = { 75 | badgePages: new BadgePagesBean(), 76 | badges: new Array(), 77 | }, action: AllStoreActions): BadgeState { 78 | 79 | switch (action.type) { 80 | /** 81 | * update all badges in store 82 | */ 83 | case LoadBadgesAction.getType(): { 84 | const badgePages = Object.assign(new BadgePagesBean(), action.payload); 85 | 86 | /** 87 | * notify badges change 88 | */ 89 | action.subject.complete(); 90 | return { 91 | badgePages: badgePages, 92 | badges: state.badges, 93 | }; 94 | } 95 | 96 | default: 97 | return state; 98 | } 99 | } 100 | 101 | /** 102 | * select this store service 103 | */ 104 | public badgePages(): Observable { 105 | return this._store.select(this.getBadgePages); 106 | } 107 | 108 | /** 109 | * select this store service 110 | */ 111 | public badges(): Observable> { 112 | return this._store.select(this.getBadges); 113 | } 114 | 115 | /** 116 | * dispatch 117 | * @param action dispatch action 118 | */ 119 | public dispatch(action: AllStoreActions) { 120 | this._store.dispatch(action); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /api/v1/badge/badge_test.go: -------------------------------------------------------------------------------- 1 | package badge_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | mocket "github.com/Selvatico/go-mocket" 8 | "github.com/gavv/httpexpect" 9 | "github.com/ovh/lhasa/api/tests" 10 | ) 11 | 12 | func TestBadgeAdd(t *testing.T) { 13 | server := tests.StartTestHTTPServer() 14 | defer server.Close() 15 | 16 | emptyListReply := []map[string]interface{}{} 17 | mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "badges" WHERE`).WithReply(emptyListReply) 18 | 19 | e := httpexpect.New(t, server.URL) 20 | 21 | bdg := ` 22 | { 23 | "name": "My Shiny Badge", 24 | "type": "enum", 25 | "levels": [ 26 | {"id": "unset", "label": "Unknown", "color": "lightgray", "isdefault": true}, 27 | {"id": "error", "label": "%d errors detected", "color": "red"}, 28 | {"id": "ok", "label": "clean", "color": "green"} 29 | ] 30 | } 31 | ` 32 | 33 | e.PUT("/api/v1/badges/myshinybadge"). 34 | WithHeader("Content-Type", "application/json"). 35 | WithText(bdg). 36 | Expect(). 37 | Status(http.StatusCreated) 38 | } 39 | 40 | func TestBadgeUpdate(t *testing.T) { 41 | server := tests.StartTestHTTPServer() 42 | defer server.Close() 43 | 44 | nonEmptyListReply := []map[string]interface{}{{"id": 12}} 45 | mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "badges" WHERE`).WithReply(nonEmptyListReply) 46 | 47 | e := httpexpect.New(t, server.URL) 48 | bdg := ` 49 | { 50 | "title": "My Shiny Badge", 51 | "type": "enum", 52 | "levels": [ 53 | {"id": "unset", "label": "Unknown", "color": "lightgray", "isdefault":true}, 54 | {"id": "error", "label": "%d errors detected", "color": "red"}, 55 | {"id": "ok", "label": "clean", "color": "green"} 56 | ] 57 | } 58 | ` 59 | e.PUT("/api/v1/badges/myshinybadge"). 60 | WithHeader("Content-Type", "application/json"). 61 | WithText(bdg). 62 | Expect(). 63 | Status(http.StatusOK) 64 | } 65 | 66 | func TestBadgeDelete(t *testing.T) { 67 | server := tests.StartTestHTTPServer() 68 | defer server.Close() 69 | 70 | listReply := []map[string]interface{}{{"id": 12}} 71 | mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "badges" WHERE`).WithReply(listReply) 72 | 73 | e := httpexpect.New(t, server.URL) 74 | 75 | e.DELETE("/api/v1/badges/myshinybadge"). 76 | Expect(). 77 | Status(http.StatusNoContent) 78 | } 79 | 80 | func TestBadgeList(t *testing.T) { 81 | server := tests.StartTestHTTPServer() 82 | defer server.Close() 83 | 84 | countReply := []map[string]interface{}{{"count(*)": 2}} 85 | listReply := []map[string]interface{}{{"title": "myshinybadge"}, {"title": "myshinybadge2"}} 86 | 87 | mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "badges" WHERE`).WithReply(listReply) 88 | mocket.Catcher.NewMock().WithQuery(`SELECT count(*) FROM "badges" WHERE "badges"."deleted_at" IS NULL`).WithReply(countReply) 89 | 90 | e := httpexpect.New(t, server.URL) 91 | jsonObj := e.GET("/api/v1/badges/"). 92 | Expect(). 93 | Status(http.StatusPartialContent). 94 | JSON().Object() 95 | 96 | jsonObj.Keys().ContainsOnly("content", "pageMetadata", "_links") 97 | pageMetadata := jsonObj.Value("pageMetadata").Object() 98 | pageMetadata.Keys().ContainsOnly("totalElements", "totalPages", "size", "number") 99 | pageMetadata.ValueEqual("totalElements", 2). 100 | ValueEqual("totalPages", 1). 101 | ValueEqual("size", 20). 102 | ValueEqual("number", 0) 103 | 104 | content := jsonObj.Value("content").Array() 105 | content.Length().Equal(2) 106 | content.Element(0).Object().ValueEqual("title", "myshinybadge") 107 | content.Element(1).Object().ValueEqual("title", "myshinybadge2") 108 | } 109 | -------------------------------------------------------------------------------- /api/hateoas/errors.go: -------------------------------------------------------------------------------- 1 | package hateoas 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | ) 7 | 8 | // UnsupportedEntityError is raised when a repository method is called with a wrong type 9 | type UnsupportedEntityError struct { 10 | Expected string 11 | Actual string 12 | } 13 | 14 | // UnsupportedIndexError is raised when a impossible indexation was requested 15 | type UnsupportedIndexError struct { 16 | Field string 17 | Supported []string 18 | } 19 | 20 | // EntityDoesNotExistError is raised when a repository try to read a non existing entity 21 | type EntityDoesNotExistError struct { 22 | EntityName string 23 | Criteria map[string]interface{} 24 | } 25 | 26 | // InternalError all internal error 27 | type InternalError struct { 28 | Message string 29 | Detail string 30 | } 31 | 32 | type errorCreated string 33 | type errorGone string 34 | 35 | // Error implements error interface for UnsupportedEntityError 36 | func (err UnsupportedEntityError) Error() string { 37 | return fmt.Sprintf("unsupported entity (expected: %s, actual: %s)", err.Expected, err.Actual) 38 | } 39 | 40 | // Error implements error interface for EntityDoesNotExistError 41 | func (err EntityDoesNotExistError) Error() string { 42 | // sorting the criteria so the error messages are predictable (useful for testing) 43 | sortedCriteria := make([]string, 0) 44 | keys := make([]string, 0) 45 | for k := range err.Criteria { 46 | keys = append(keys, k) 47 | } 48 | sort.Strings(keys) 49 | for _, k := range keys { 50 | sortedCriteria = append(sortedCriteria, fmt.Sprintf("%s=%s", k, err.Criteria[k])) 51 | } 52 | return fmt.Sprintf("entity %s%s does not exist", err.EntityName, sortedCriteria) 53 | } 54 | 55 | // Error implements error interface for UnsupportedIndexError 56 | func (err UnsupportedIndexError) Error() string { 57 | return fmt.Sprintf("index by %s is not supported (one of %v)", err.Field, err.Supported) 58 | } 59 | 60 | // Error implements error interface 61 | func (err *InternalError) Error() string { 62 | return err.Message + ":" + err.Detail 63 | } 64 | 65 | // Error implements error interface 66 | func (err errorCreated) Error() string { 67 | return string(err) 68 | } 69 | 70 | // Error implements error interface 71 | func (err errorGone) Error() string { 72 | return string(err) 73 | } 74 | 75 | // ErrorCreated is raised when no error occurs but a resource has been created (tonic single-status code workaround) 76 | var ErrorCreated = errorCreated("created") 77 | 78 | // ErrorGone is raised when a former resource has been requested but no longer exist 79 | var ErrorGone = errorGone("gone") 80 | 81 | //NewUnsupportedEntityError is a helper to create an UnsupportedEntityError 82 | func NewUnsupportedEntityError(expected, actual interface{}) error { 83 | return UnsupportedEntityError{ 84 | Expected: fmt.Sprintf("%T", expected), 85 | Actual: fmt.Sprintf("%T", actual), 86 | } 87 | } 88 | 89 | // NewEntityDoesNotExistError is a helper to create an EntityDoesNotExistError 90 | func NewEntityDoesNotExistError(entity interface{}, criteria map[string]interface{}) error { 91 | return EntityDoesNotExistError{ 92 | EntityName: fmt.Sprintf("%T", entity), 93 | Criteria: criteria, 94 | } 95 | } 96 | 97 | // NewUnsupportedIndexError is a helper to create an UnsupportedIndexError 98 | func NewUnsupportedIndexError(field string, supported ...string) error { 99 | return UnsupportedIndexError{ 100 | Field: field, 101 | Supported: supported, 102 | } 103 | } 104 | 105 | // IsEntityDoesNotExistError returns true if err is an EntityDoesNotExistError 106 | func IsEntityDoesNotExistError(err error) bool { 107 | _, ok := err.(EntityDoesNotExistError) 108 | return ok 109 | } 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lhasa 2 | 3 | Urba planning for microservices. 4 | 5 | List and map applications of your microservices information system and how they interact. Track your business 6 | processes and domains, then gamify the continuous enhancement of your system. 7 | 8 | [![goreportcard](https://goreportcard.com/badge/github.com/ovh/lhasa)](https://goreportcard.com/report/github.com/ovh/lhasa) 9 | 10 | This project is at pre-alpha state and under active development. 11 | 12 | ## Features 13 | 14 | ### Application catalog 15 | 16 | Applications of your information system are grouped in *business domains*. 17 | Meta data about each application version should be stored directly in the codebase (see: manifest files) and are 18 | collected to be presented in the user interface. 19 | 20 | ![application](docs/images/application.png) 21 | 22 | ### Deployment tracking 23 | 24 | Each time you deploy a release on a given environment you can notify *Lhasa* API and track which version is available on 25 | each environment. 26 | 27 | ![deployments tracking](docs/images/deployments.png) 28 | 29 | ## Contact 30 | 31 | Made with love by OVH Urban Planning team. 32 | 33 | ### Authors 34 | 35 | * Rayene Ben Rayana 36 | * Fabien Meurillon 37 | * Yannick Roffin 38 | 39 | ## Build and run project 40 | 41 | ### Technical overview 42 | 43 | This project is currently at very early stage and under active development. It is mainly written in golang and in angular5. 44 | 45 | ### Build project 46 | 47 | #### Requirements 48 | 49 | * Git 50 | * [Go installation](https://golang.org/doc/install) and [workspace](https://golang.org/doc/code.html#Workspaces) (`GOROOT` and `GOPATH` correctly set) 51 | * GNU Make 52 | * [dep](https://github.com/golang/dep) - Go dependency management tool 53 | * [Angular 5 CLI](https://angular.io/guide/quickstart) - Management CLI for angular 5 54 | 55 | #### Steps 56 | 57 | Go get the project, then run the default Makefile target: 58 | 59 | ``` 60 | go get github.com/ovh/lhasa/api/cmd/appcatalog 61 | cd $GOPATH/src/github.com/ovh/lhasa 62 | make 63 | ``` 64 | 65 | ### Run project 66 | 67 | #### Requirements 68 | 69 | * PostgreSQL 9.4 or later 70 | 71 | ##### Steps 72 | 73 | ###### Start requirements 74 | 75 | If you don't have a postgres instance, you can start one locally using [docker-compose]: 76 | 77 | ``` 78 | $ PORT=100 docker-compose up # $PORT is appended with '32' as listening port. "10032" in this example. 79 | ``` 80 | 81 | [docker-compose]: https://docs.docker.com/compose/ 82 | 83 | ###### Configuration file 84 | 85 | Copy `config.json.dist` as `config.json` and edit the latest to match your database configuration: 86 | 87 | ```json 88 | { 89 | "appcatalog-db": { 90 | "writers": [ 91 | { 92 | "host": "localhost", 93 | "port": 10032, 94 | "sslmode": "disable" 95 | } 96 | ], 97 | "database": "postgres", 98 | "user": "postgres", 99 | "password": "appcatalog", 100 | "type": "postgresql" 101 | } 102 | } 103 | ``` 104 | 105 | ###### Start Lhasa 106 | 107 | ``` 108 | cd $GOPATH/src/github.com/ovh/lhasa 109 | APPCATALOG_AUTO_MIGRATE=1 make run 110 | ``` 111 | 112 | Lhasa will start and listen on port `8081`. 113 | 114 | Note that `APPCATALOG_AUTO_MIGRATE=1` will perform database schema migrations/creations automatically. 115 | 116 | ###### Load the sample dataset (optional) 117 | 118 | Optionally, you can load the sample dataset to immediately start playing: 119 | 120 | ``` 121 | API_BASE_URL=http://localhost:8081/api ./samples/mycompany.sh 122 | ``` 123 | -------------------------------------------------------------------------------- /webui/src/app/components/graphs/graph-browse.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, ViewChild } from '@angular/core'; 2 | import { GraphBean, NodeBean, GraphVis } from '../../models/graph/graph-bean'; 3 | import { Observable } from 'rxjs'; 4 | import { AutoUnsubscribe } from '../../shared/decorator/autoUnsubscribe'; 5 | import { GraphsStoreService } from './../../stores/graphs-store.service'; 6 | import { CytoGraphComponent } from '../../widget/cytograph/cytograph.component'; 7 | import { environment } from '../../../environments/environment'; 8 | 9 | @Component({ 10 | selector: 'app-graph-browse', 11 | templateUrl: './graph-browse.component.html', 12 | styleUrls: [], 13 | }) 14 | 15 | @AutoUnsubscribe() 16 | export class GraphBrowseComponent implements OnInit { 17 | 18 | @ViewChild('cytograph') cytograph: CytoGraphComponent; 19 | protected deploymentStream: Observable; 20 | private api: any; 21 | 22 | constructor( 23 | private graphsStoreService: GraphsStoreService, 24 | ) { 25 | this.deploymentStream = this.graphsStoreService.deployments(); 26 | } 27 | 28 | private _graphData: any = { 29 | nodes: [], 30 | edges: [] 31 | }; 32 | 33 | ngOnInit() { 34 | this.deploymentStream.subscribe( 35 | (graph: GraphBean) => { 36 | const dejaVuDomains = {}; 37 | const dejaVuEnvironments = {}; 38 | const dejaVuNodes = {}; 39 | if (graph.nodes === undefined || graph.edges === undefined) { 40 | return; 41 | } 42 | graph.nodes.forEach((node, index) => { 43 | if (index > 10000) { 44 | return; 45 | } 46 | dejaVuNodes[node.id] = true; 47 | const env = node.properties.environment.slug; 48 | const domain = env + '/' + node.properties.application.domain; 49 | if (dejaVuEnvironments[env] === undefined) { 50 | this._graphData.nodes.push({ 51 | classes: 'environment', 52 | data: { 53 | id: env, 54 | type: 'environment', 55 | color: node.properties.environment.properties.color || 'gray', 56 | name: env, 57 | } 58 | }); 59 | dejaVuEnvironments[env] = true; 60 | } 61 | if (dejaVuDomains[domain] === undefined) { 62 | this._graphData.nodes.push({ 63 | classes: 'domain', 64 | data: { 65 | id: domain, 66 | name: node.properties.application.domain, 67 | color: node.properties.environment.properties.color || 'gray', 68 | type: 'domain', 69 | parent: env, 70 | } 71 | }); 72 | dejaVuDomains[domain] = true; 73 | } 74 | this._graphData.nodes.push({ 75 | classes: 'application', 76 | data: { 77 | id: node.id, 78 | name: node.name, 79 | type: 'application', 80 | domain: node.properties.application.domain, 81 | parent: domain, 82 | } 83 | }); 84 | }); 85 | graph.edges.forEach(edge => { 86 | if (dejaVuNodes[edge.from] === undefined || dejaVuNodes[edge.to] === undefined) { 87 | return; 88 | } 89 | this._graphData.edges.push({ 90 | data: { 91 | source: edge.from, 92 | target: edge.to, 93 | } 94 | }); 95 | }); 96 | this.cytograph.load(this._graphData); 97 | }, 98 | error => { 99 | console.error(error); 100 | }, 101 | () => { 102 | } 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /webui/src/app/resolver/resolve-badges.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, Subject } from 'rxjs'; 2 | import { Observable } from 'rxjs/Rx'; 3 | import 'rxjs/add/observable/from'; 4 | import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 5 | import { Injectable } from '@angular/core'; 6 | import { BadgeBean, BadgePagesBean } from '../models/commons/badges-bean'; 7 | import { LoadBadgesAction, BadgesStoreService } from '../stores/badges-store.service'; 8 | import { ContentListResponse, PageMetaData } from '../models/commons/entity-bean'; 9 | import { DataDeploymentService } from '../services/data-deployment.service'; 10 | import { DataBadgeService } from '../services/data-badge.service'; 11 | import { DataBadgeStatsService } from '../services/data-badgestats.service'; 12 | import { LoadersStoreService } from '../stores/loader-store.service'; 13 | import { NewErrorAction, ErrorBean, ErrorsStoreService } from '../stores/errors-store.service'; 14 | 15 | @Injectable() 16 | export class BadgesResolver implements Resolve { 17 | constructor( 18 | private badgesStoreService: BadgesStoreService, 19 | private badgesService: DataBadgeService, 20 | private badgeStatsService: DataBadgeStatsService, 21 | private loadersStoreService: LoadersStoreService, 22 | private errorsStoreService: ErrorsStoreService, 23 | ) { } 24 | 25 | resolve( 26 | route: ActivatedRouteSnapshot, 27 | state: RouterStateSnapshot 28 | ): Observable | Promise | any { 29 | return this.selectBadges({ 30 | number: route.queryParams.page || 0, 31 | size: 40 32 | }, new BehaviorSubject('select all badges')); 33 | } 34 | 35 | /** 36 | * dispatch load badges 37 | * @param event 38 | */ 39 | public selectBadges(metadata: PageMetaData, subject: Subject): Subject { 40 | this.loadersStoreService.notify(subject); 41 | // load all badges 42 | const meta: { 43 | [key: string]: any | any[]; 44 | } = { 45 | size: metadata.size, 46 | page: metadata.number 47 | }; 48 | this.badgesService.GetAllFromContent('', meta).subscribe( 49 | (data: ContentListResponse) => { 50 | if (data.content.length == 0){ 51 | this.badgesStoreService.dispatch( 52 | new LoadBadgesAction({ 53 | badges: data.content, 54 | metadata: data.pageMetadata, 55 | }, subject) 56 | ); 57 | } 58 | Observable 59 | .from(data.content) 60 | .map(badge => this.badgeStatsService.GetBadgeStats(badge.slug)) 61 | .zipAll() 62 | .subscribe((stats: Array>) => { 63 | stats.forEach((stat, index) => { 64 | data.content[index]._stats = stat; 65 | 66 | }); 67 | this.badgesStoreService.dispatch( 68 | new LoadBadgesAction({ 69 | badges: data.content, 70 | metadata: data.pageMetadata, 71 | }, subject) 72 | ); 73 | }) 74 | }, 75 | (error) => { 76 | this.errorsStoreService.dispatch(new NewErrorAction( 77 | { 78 | code: 'ERROR-BADGES', 79 | stack: JSON.stringify(error, null, 2), 80 | }, subject 81 | )); 82 | } 83 | ); 84 | return subject; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /webui/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-shadowed-variable": true, 69 | "no-string-literal": false, 70 | "no-string-throw": true, 71 | "no-switch-case-fall-through": true, 72 | "no-trailing-whitespace": true, 73 | "no-unnecessary-initializer": true, 74 | "no-unused-expression": true, 75 | "no-unused-variable": false, 76 | "no-use-before-declare": true, 77 | "no-var-keyword": true, 78 | "object-literal-sort-keys": false, 79 | "one-line": [ 80 | true, 81 | "check-open-brace", 82 | "check-catch", 83 | "check-else", 84 | "check-whitespace" 85 | ], 86 | "prefer-const": true, 87 | "quotemark": [ 88 | true, 89 | "single" 90 | ], 91 | "radix": true, 92 | "semicolon": [ 93 | true, 94 | "always" 95 | ], 96 | "triple-equals": [ 97 | true, 98 | "allow-null-check" 99 | ], 100 | "typedef-whitespace": [ 101 | true, 102 | { 103 | "call-signature": "nospace", 104 | "index-signature": "nospace", 105 | "parameter": "nospace", 106 | "property-declaration": "nospace", 107 | "variable-declaration": "nospace" 108 | } 109 | ], 110 | "typeof-compare": true, 111 | "unified-signatures": true, 112 | "variable-name": false, 113 | "whitespace": [ 114 | true, 115 | "check-branch", 116 | "check-decl", 117 | "check-operator", 118 | "check-separator", 119 | "check-type", 120 | "check-module" 121 | ], 122 | "directive-selector": [ 123 | true, 124 | "attribute", 125 | "app", 126 | "camelCase" 127 | ], 128 | "component-selector": [ 129 | true, 130 | "element", 131 | "app", 132 | "kebab-case" 133 | ], 134 | "no-output-on-prefix": true, 135 | "use-input-property-decorator": true, 136 | "use-output-property-decorator": true, 137 | "use-host-property-decorator": true, 138 | "no-input-rename": true, 139 | "no-output-rename": true, 140 | "use-life-cycle-interface": true, 141 | "use-pipe-transform-interface": true, 142 | "component-class-suffix": true, 143 | "directive-class-suffix": true 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /webui/src/app/stores/errors-store.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { createFeatureSelector, createSelector, Selector, Store } from '@ngrx/store'; 3 | import { ActionWithPayloadAndPromise } from './action-with-payload'; 4 | import { ApplicationBean, ApplicationPagesBean, DeploymentBean, DomainBean, DomainPagesBean } from '../models/commons/applications-bean'; 5 | import { Subject, Observable } from 'rxjs'; 6 | import { GraphBean } from '../models/graph/graph-bean'; 7 | 8 | import { remove } from 'lodash'; 9 | 10 | // Error 11 | export class ErrorBean { 12 | code: string; 13 | stack?: any; 14 | } 15 | 16 | /** 17 | * states 18 | */ 19 | export interface ErrorsState { 20 | /** 21 | * store each graph with a key 22 | */ 23 | errors: ErrorBean[]; 24 | } 25 | 26 | /** 27 | * actions 28 | */ 29 | export class NewErrorAction implements ActionWithPayloadAndPromise { 30 | readonly type = NewErrorAction.getType(); 31 | 32 | public static getType(): string { 33 | return 'NewErrorAction'; 34 | } 35 | 36 | constructor(public payload: ErrorBean, public subject?: Subject) { 37 | } 38 | } 39 | 40 | export class DropErrorAction implements ActionWithPayloadAndPromise { 41 | readonly type = DropErrorAction.getType(); 42 | 43 | public static getType(): string { 44 | return 'DropErrorAction'; 45 | } 46 | 47 | constructor(public payload: ErrorBean, public subject?: Subject) { 48 | } 49 | } 50 | 51 | export type AllStoreActions = NewErrorAction | DropErrorAction; 52 | 53 | /** 54 | * main store for this Graph 55 | */ 56 | @Injectable() 57 | export class ErrorsStoreService { 58 | 59 | readonly getErrors: Selector; 60 | 61 | /** 62 | * 63 | * @param _store constructor 64 | */ 65 | constructor( 66 | private _store: Store 67 | ) { 68 | this.getErrors = ErrorsStoreService.create((state: ErrorsState) => state.errors); 69 | } 70 | 71 | /** 72 | * create a selector 73 | * @param handler internal static 74 | */ 75 | private static create(handler: (S1: ErrorsState) => any) { 76 | return createSelector(createFeatureSelector('errors'), handler); 77 | } 78 | 79 | /** 80 | * metareducer (Cf. https://www.concretepage.com/angular-2/ngrx/ngrx-store-4-angular-5-tutorial) 81 | * @param state 82 | * @param action 83 | */ 84 | public static reducer(state: ErrorsState = { 85 | errors: [], 86 | }, action: AllStoreActions): ErrorsState { 87 | 88 | switch (action.type) { 89 | /** 90 | * update store 91 | */ 92 | case NewErrorAction.getType(): { 93 | const errors = Object.assign([], state.errors); 94 | errors.push(action.payload); 95 | 96 | // Complete load action 97 | action.subject.complete(); 98 | return { 99 | errors: errors, 100 | }; 101 | } 102 | 103 | /** 104 | * update store 105 | */ 106 | case DropErrorAction.getType(): { 107 | const errors = Object.assign([], state.errors); 108 | 109 | remove(errors, (error) => { 110 | return error.code === action.payload.code; 111 | }); 112 | errors.push(action.payload); 113 | 114 | // Complete load action 115 | action.subject.complete(); 116 | return { 117 | errors: errors, 118 | }; 119 | } 120 | 121 | default: 122 | return state; 123 | } 124 | } 125 | 126 | /** 127 | * select this store service 128 | */ 129 | public errors(): Observable { 130 | return this._store.select(this.getErrors); 131 | } 132 | 133 | /** 134 | * dispatch 135 | * @param action dispatch action 136 | */ 137 | public dispatch(action: AllStoreActions) { 138 | this._store.dispatch(action); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /api/hateoas/utils.go: -------------------------------------------------------------------------------- 1 | package hateoas 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/jinzhu/gorm" 10 | ) 11 | 12 | // ToJSON simple indented json 13 | func ToJSON(data interface{}) string { 14 | byt, _ := json.MarshalIndent(data, "", "\t") 15 | return string(byt) 16 | } 17 | 18 | // JSONBFilter filter jsonb 19 | func JSONBFilter(db *gorm.DB, criteria map[string]interface{}) *gorm.DB { 20 | for k, v := range criteria { 21 | values := strings.Split(v.(string), ",") 22 | switch len(values) { 23 | case 1: 24 | db = db.Where(k+" (?)", values[0]) 25 | case 2: 26 | db = db.Where(k+" (?,?)", values[0], values[1]) 27 | } 28 | } 29 | return db 30 | } 31 | 32 | // InlineFilter filter inline filters 33 | func InlineFilter(db *gorm.DB, criteria map[string]interface{}) *gorm.DB { 34 | for k, v := range criteria { 35 | values := strings.Split(v.(string), ",") 36 | switch len(values) { 37 | case 1: 38 | db = db.Where(k, values[0]) 39 | case 2: 40 | db = db.Where(k, values[0], values[1]) 41 | } 42 | } 43 | return db 44 | } 45 | 46 | // CheckFilter analyse filters 47 | func CheckFilter(criteria map[string]interface{}) (map[string]interface{}, map[string]interface{}, map[string]interface{}) { 48 | // Analyse critarias for extract inline, standard and JSONB ones 49 | standardCriterias := make(map[string]interface{}) 50 | inlineCriterias := make(map[string]interface{}) 51 | jsonbCriterias := make(map[string]interface{}) 52 | for k, v := range criteria { 53 | if strings.Contains(k, ".") { 54 | var values = strings.Split(k, ".") 55 | key := values[0] + "->>'" + values[1] + "' in " 56 | jsonbCriterias[key] = v 57 | } else if strings.Contains(k, "@>") { 58 | var values = strings.Split(k, "@") 59 | key := values[0] + " @>" 60 | jsonbCriterias[key] = v 61 | } else { 62 | if strings.Contains(k, "?") { 63 | inlineCriterias[k] = v 64 | } else { 65 | standardCriterias[k] = v 66 | } 67 | } 68 | } 69 | return standardCriterias, inlineCriterias, jsonbCriterias 70 | } 71 | 72 | // BaseURL returns the base path that has been used to access current resource 73 | func BaseURL(c *gin.Context) string { 74 | basePath, ok := c.Get(hateoasBasePathKey) 75 | if ok { 76 | return basePath.(string) 77 | } 78 | return c.Request.URL.EscapedPath() 79 | } 80 | 81 | // GetGormSortClause returns the SQL-escaped sort clause 82 | func (p Pageable) GetGormSortClause() interface{} { 83 | if sortClause := p.GetSortClause(); sortClause != "1" { 84 | return sortClause 85 | } 86 | // wrap column pointer in a gorm expression to avoid quote-surrounding 87 | return gorm.Expr("1") 88 | } 89 | 90 | // GetSortClause returns the SQL-escaped sort clause 91 | func (p Pageable) GetSortClause() string { 92 | // if not sort column was specified, optimistically use first column to preserve page consistency 93 | if p.Sort == "" { 94 | return "1" 95 | } 96 | fields := strings.Split(p.Sort, ",") 97 | for i, field := range fields { 98 | // for each field to sort, read sort direction after a semicolon, asc will be used as default 99 | direction := directionAsc 100 | if fieldClause := strings.Split(field, ";"); len(fieldClause) == 2 { 101 | field = fieldClause[0] 102 | if strings.ToLower(fieldClause[1]) == directionDesc { 103 | direction = directionDesc 104 | } 105 | } 106 | // %q sanitizes the field double-quotes to prevent sql injections 107 | fields[i] = fmt.Sprintf("%q %s", field, direction) 108 | } 109 | return strings.Join(fields, ", ") 110 | } 111 | 112 | // GetOffset returns the page offset 113 | func (p Pageable) GetOffset() int { 114 | return p.Page * p.Size 115 | } 116 | 117 | // NewPage initialize an empty resource page 118 | func NewPage(pageable Pageable, defaultPageSize int, basePath string) Page { 119 | if pageable.Size == 0 { 120 | pageable.Size = defaultPageSize 121 | } 122 | return Page{Pageable: pageable, BasePath: basePath} 123 | } 124 | -------------------------------------------------------------------------------- /webui/src/app/stores/help-store.service.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject , Subject } from 'rxjs'; 2 | import { Injectable } from '@angular/core'; 3 | import { createFeatureSelector, createSelector, Selector, Store } from '@ngrx/store'; 4 | 5 | import { ActionWithPayloadAndPromise } from './action-with-payload'; 6 | import { ContentBean } from '../models/commons/content-bean'; 7 | import { DataContentService } from '../services/data-content.service'; 8 | import { LoadersStoreService } from './loader-store.service'; 9 | import { ErrorsStoreService, NewErrorAction, ErrorBean } from './errors-store.service'; 10 | import { Observable } from 'rxjs'; 11 | 12 | // Help 13 | export class HelpBean { 14 | token: string; 15 | content: ContentBean; 16 | } 17 | 18 | /** 19 | * states 20 | */ 21 | export interface HelpState { 22 | /** 23 | * help 24 | */ 25 | help: HelpBean; 26 | } 27 | 28 | /** 29 | * actions 30 | */ 31 | export class GetHelpAction implements ActionWithPayloadAndPromise { 32 | readonly type = GetHelpAction.getType(); 33 | 34 | public static getType(): string { 35 | return 'GetHelpAction'; 36 | } 37 | 38 | constructor(public payload: HelpBean, public subject?: Subject) { 39 | } 40 | } 41 | 42 | export type AllStoreActions = GetHelpAction; 43 | 44 | /** 45 | * main store for this application 46 | */ 47 | @Injectable() 48 | export class HelpsStoreService { 49 | 50 | readonly getHelp: Selector; 51 | 52 | /** 53 | * 54 | * @param _store constructor 55 | */ 56 | constructor( 57 | private _store: Store, 58 | private _content: DataContentService, 59 | private loadersStoreService: LoadersStoreService, 60 | private errorsStoreService: ErrorsStoreService, 61 | ) { 62 | this.getHelp = HelpsStoreService.create((state: HelpState) => state.help); 63 | } 64 | 65 | /** 66 | * create a selector 67 | * @param handler internal static 68 | */ 69 | private static create(handler: (S1: HelpState) => any) { 70 | return createSelector(createFeatureSelector('helps'), handler); 71 | } 72 | 73 | /** 74 | * metareducer (Cf. https://www.concretepage.com/angular-2/ngrx/ngrx-store-4-angular-5-tutorial) 75 | * @param state 76 | * @param action 77 | */ 78 | public static reducer(state: HelpState = { 79 | help: new HelpBean(), 80 | }, action: AllStoreActions): HelpState { 81 | 82 | switch (action.type) { 83 | /** 84 | * add a new loader 85 | */ 86 | case GetHelpAction.getType(): { 87 | const n = Object.assign({}, action.payload); 88 | action.subject.complete(); 89 | return { 90 | help: n, 91 | }; 92 | } 93 | default: 94 | return state; 95 | } 96 | } 97 | 98 | /** 99 | * select this store service 100 | */ 101 | public help(): Observable { 102 | return this._store.select(this.getHelp); 103 | } 104 | 105 | /** 106 | * dispatch 107 | * @param action dispatch action 108 | */ 109 | public dispatch(action: AllStoreActions) { 110 | this._store.dispatch(action); 111 | } 112 | 113 | /** 114 | * dispatch 115 | * @param action dispatch action 116 | */ 117 | public request(key: string) { 118 | const subject: Subject = new BehaviorSubject('select help token ' + key); 119 | this.loadersStoreService.notify(subject); 120 | this._content.GetSingle(key).subscribe( 121 | (data: ContentBean) => { 122 | this.dispatch( 123 | new GetHelpAction({ 124 | token: key, 125 | content: data 126 | }, subject)); 127 | }, 128 | (error) => { 129 | // When errors only print a default help value 130 | this.dispatch( 131 | new GetHelpAction({ 132 | token: key, 133 | content: 'TBD ...' 134 | }, subject)); 135 | } 136 | ); 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /webui/src/app/stores/config-store.service.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject , Subject, Observable } from 'rxjs'; 2 | import { Injectable } from '@angular/core'; 3 | import { createFeatureSelector, createSelector, Selector, Store } from '@ngrx/store'; 4 | 5 | import { ActionWithPayloadAndPromise } from './action-with-payload'; 6 | import { ContentBean } from '../models/commons/content-bean'; 7 | import { DataContentService } from '../services/data-content.service'; 8 | import { LoadersStoreService } from './loader-store.service'; 9 | import { ErrorsStoreService, NewErrorAction, ErrorBean } from './errors-store.service'; 10 | 11 | // Config 12 | export class ConfigBean { 13 | config: any; 14 | } 15 | 16 | /** 17 | * states 18 | */ 19 | export interface ConfigState { 20 | /** 21 | * help 22 | */ 23 | config: ConfigBean; 24 | } 25 | 26 | /** 27 | * actions 28 | */ 29 | export class SetConfigAction implements ActionWithPayloadAndPromise { 30 | readonly type = SetConfigAction.getType(); 31 | 32 | public static getType(): string { 33 | return 'SetConfigAction'; 34 | } 35 | 36 | constructor(public payload: ConfigBean, public subject?: Subject) { 37 | } 38 | } 39 | 40 | export type AllStoreActions = SetConfigAction; 41 | 42 | /** 43 | * main store for this application 44 | */ 45 | @Injectable() 46 | export class ConfigStoreService { 47 | 48 | readonly getConfig: Selector; 49 | 50 | /** 51 | * 52 | * @param _store constructor 53 | */ 54 | constructor( 55 | private _store: Store, 56 | private _content: DataContentService, 57 | private loadersStoreService: LoadersStoreService, 58 | private errorsStoreService: ErrorsStoreService, 59 | ) { 60 | this.getConfig = ConfigStoreService.create((state: ConfigState) => state.config); 61 | } 62 | 63 | /** 64 | * create a selector 65 | * @param handler internal static 66 | */ 67 | private static create(handler: (S1: ConfigState) => any) { 68 | return createSelector(createFeatureSelector('config'), handler); 69 | } 70 | 71 | /** 72 | * metareducer (Cf. https://www.concretepage.com/angular-2/ngrx/ngrx-store-4-angular-5-tutorial) 73 | * @param state 74 | * @param action 75 | */ 76 | public static reducer(state: ConfigState = { 77 | config: new ConfigBean(), 78 | }, action: AllStoreActions): ConfigState { 79 | 80 | switch (action.type) { 81 | /** 82 | * add a new loader 83 | */ 84 | case SetConfigAction.getType(): { 85 | const n = Object.assign({}, action.payload); 86 | action.subject.complete(); 87 | return { 88 | config: n, 89 | }; 90 | } 91 | default: 92 | return state; 93 | } 94 | } 95 | 96 | /** 97 | * select this store service 98 | */ 99 | public help(): Observable { 100 | return this._store.select(this.getConfig); 101 | } 102 | 103 | /** 104 | * dispatch 105 | * @param action dispatch action 106 | */ 107 | public dispatch(action: AllStoreActions) { 108 | this._store.dispatch(action); 109 | } 110 | 111 | /** 112 | * dispatch 113 | * @param action dispatch action 114 | */ 115 | public request(key: string) { 116 | const subject: Subject = new BehaviorSubject('select config token ' + key); 117 | this.loadersStoreService.notify(subject); 118 | this._content.GetSingle(key).subscribe( 119 | (data: ContentBean) => { 120 | let configuration; 121 | try { 122 | configuration = JSON.parse(data.toString()); 123 | } catch(e) { 124 | console.warn(e); 125 | configuration = {}; 126 | } 127 | this.dispatch( 128 | new SetConfigAction({ 129 | config: configuration 130 | }, subject)); 131 | }, 132 | (error) => { 133 | // When errors load a default config 134 | this.dispatch( 135 | new SetConfigAction({ 136 | config: {} 137 | }, subject)); 138 | } 139 | ); 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /webui/src/app/components/domains-browse/domains-browse.component.ts: -------------------------------------------------------------------------------- 1 | import { Router } from '@angular/router'; 2 | import { Component, OnInit, ViewChild } from '@angular/core'; 3 | import { ApplicationsStoreService, LoadApplicationsAction, SelectApplicationAction } from '../../stores/applications-store.service'; 4 | import { Store } from '@ngrx/store'; 5 | import { ApplicationBean, DeploymentBean, DomainBean, DomainPagesBean } from '../../models/commons/applications-bean'; 6 | import { DataApplicationService } from '../../services/data-application-version.service'; 7 | import { ContentListResponse, PageMetaData } from '../../models/commons/entity-bean'; 8 | import { find } from 'lodash'; 9 | 10 | import { DataDeploymentService } from '../../services/data-deployment.service'; 11 | import { UiKitPaginate } from '../../models/kit/paginate'; 12 | import { ActivatedRoute } from '@angular/router'; 13 | import { DomainsResolver } from '../../resolver/resolve-domains'; 14 | import { BehaviorSubject } from 'rxjs'; 15 | import { OuiPaginationComponent } from '../../kit/oui-pagination/oui-pagination.component'; 16 | import { Observable } from 'rxjs'; 17 | 18 | @Component({ 19 | selector: 'app-domains-browse', 20 | templateUrl: './domains-browse.component.html', 21 | styleUrls: ['./domains-browse.component.css'], 22 | }) 23 | export class DomainsBrowseComponent implements OnInit { 24 | 25 | @ViewChild('paginationtop') paginationtop: OuiPaginationComponent; 26 | @ViewChild('paginationbottom') paginationbottom: OuiPaginationComponent; 27 | 28 | /** 29 | * internal streams and store 30 | */ 31 | protected domainsStream: Observable; 32 | public domains: DomainBean[] = []; 33 | public metadata: UiKitPaginate = { 34 | totalElements: 0, 35 | totalPages: 0, 36 | size: 0, 37 | number: 0 38 | }; 39 | 40 | public param = { target: 'applications' }; 41 | public page = 0; 42 | 43 | constructor( 44 | private router: Router, 45 | private route: ActivatedRoute, 46 | private applicationsStoreService: ApplicationsStoreService, 47 | private domainsResolver: DomainsResolver 48 | ) { 49 | // Subscribe to retrieve page asked 50 | this.route 51 | .queryParams 52 | .subscribe(params => { 53 | // Defaults to 0 if no query param provided. 54 | this.page = +params['page'] || 0; 55 | }); 56 | 57 | } 58 | 59 | ngOnInit() { 60 | /** 61 | * subscribe 62 | */ 63 | this.domainsStream = this.applicationsStoreService.domainPages(); 64 | 65 | this.domainsStream.subscribe( 66 | (elem: DomainPagesBean) => { 67 | this.domains = elem.domains; 68 | this.metadata = { 69 | totalElements: elem.metadata.totalElements, 70 | totalPages: elem.metadata.totalPages, 71 | size: elem.metadata.size, 72 | number: elem.metadata.number 73 | }; 74 | }, 75 | error => { 76 | console.error(error); 77 | }, 78 | () => { 79 | } 80 | ); 81 | // if page different from 0 82 | if (this.page !== 0) { 83 | this.paginationtop.RefreshMetadata(this.metadata, 'select', this.page); 84 | this.paginationbottom.RefreshMetadata(this.metadata, 'select', this.page); 85 | } 86 | } 87 | 88 | /** 89 | * change selection 90 | */ 91 | public onSelect(event: any) { 92 | const metadata: PageMetaData = { 93 | totalElements: event.data.metadata.totalElements, 94 | totalPages: event.data.metadata.totalPages, 95 | size: event.data.metadata.size, 96 | number: event.data.page 97 | }; 98 | // Change page 99 | this.navigate(metadata, event.data.page); 100 | } 101 | 102 | /** 103 | * change navigation 104 | */ 105 | public navigate(metadata: PageMetaData, page: number) { 106 | // navigate if needed 107 | if (page !== this.page) { 108 | // Refresh query params 109 | this.router.navigate([], { queryParams: { page: page } }); 110 | this.domainsResolver.selectDomains(metadata, new BehaviorSubject('select another page on domains')); 111 | this.page = page; 112 | } 113 | } 114 | 115 | } 116 | --------------------------------------------------------------------------------