├── snap ├── local │ ├── web │ └── watch ├── hooks │ ├── post-refresh │ ├── connect-plug-mount-observe │ ├── connect-plug-lxd │ └── configure └── snapcraft.yaml ├── ssh ├── sshd_config └── ssh_config ├── webapp ├── public │ ├── favicon.ico │ ├── robots.txt │ ├── static │ │ └── images │ │ │ ├── fabrica.png │ │ │ ├── ico_16px.png │ │ │ ├── ico_32px.png │ │ │ ├── square.svg │ │ │ ├── show.svg │ │ │ ├── check-square.svg │ │ │ ├── download.svg │ │ │ └── delete.svg │ ├── manifest.json │ └── index.html ├── src │ ├── index.js │ ├── setupTests.js │ ├── components │ │ ├── constants.js │ │ ├── Footer.js │ │ ├── DetailsCard.js │ │ ├── RepoActions.js │ │ ├── BuildStatus.js │ │ ├── Utils.js │ │ ├── SystemMonitor.js │ │ ├── BuildActions.js │ │ ├── Settings.js │ │ ├── ImageList.js │ │ ├── ConnectionList.js │ │ ├── RepoAdd.js │ │ ├── RepoDelete.js │ │ ├── Messages.js │ │ ├── KeysAdd.js │ │ ├── api.js │ │ ├── Header.js │ │ ├── KeysList.js │ │ ├── BuildLog.js │ │ ├── BuildList.js │ │ ├── RepoList.js │ │ └── Home.js │ ├── App.test.js │ ├── scss │ │ ├── _settings.scss │ │ ├── _patterns_navigation.scss │ │ ├── _vanilla-overrides.scss │ │ └── index.scss │ ├── App.js │ ├── logo.svg │ └── serviceWorker.js ├── build.sh ├── .gitignore ├── package.json └── README.md ├── .gitignore ├── .travis.yml ├── config └── config.go ├── go.mod ├── service ├── system │ ├── snapctl.go │ └── system.go ├── writecloser │ ├── dbwrite.go │ ├── flagwrite.go │ └── downloadwrite.go ├── repo │ ├── build_actions.go │ ├── repo.go │ └── build.go ├── key │ └── key.go ├── lxd │ ├── image.go │ ├── lxd.go │ └── runner.go ├── service.go ├── rsa.go └── watch │ └── watch.go ├── web ├── middleware.go ├── lxd.go ├── index.go ├── system.go ├── buildlog.go ├── key.go ├── build.go ├── repo.go ├── response.go └── web.go ├── datastore ├── datastore.go └── sqlite │ ├── buildlog.go │ ├── database.go │ ├── settings.go │ ├── crypt.go │ ├── key.go │ ├── repo.go │ └── build.go ├── fabrica.go ├── bin └── init.py ├── README.md ├── domain └── entity.go ├── docs └── API.md └── go.sum /snap/local/web: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd $SNAP 4 | bin/fabrica 5 | -------------------------------------------------------------------------------- /snap/local/watch: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd $SNAP 4 | bin/fabrica -mode watch 5 | -------------------------------------------------------------------------------- /ssh/sshd_config: -------------------------------------------------------------------------------- 1 | # Allow client to pass locale environment variables 2 | AcceptEnv LANG LC_* -------------------------------------------------------------------------------- /snap/hooks/post-refresh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | snapctl restart ${SNAP_NAME}.init 2>&1 || true 4 | 5 | -------------------------------------------------------------------------------- /webapp/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ogra1/fabrica/HEAD/webapp/public/favicon.ico -------------------------------------------------------------------------------- /webapp/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /webapp/public/static/images/fabrica.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ogra1/fabrica/HEAD/webapp/public/static/images/fabrica.png -------------------------------------------------------------------------------- /webapp/public/static/images/ico_16px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ogra1/fabrica/HEAD/webapp/public/static/images/ico_16px.png -------------------------------------------------------------------------------- /webapp/public/static/images/ico_32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ogra1/fabrica/HEAD/webapp/public/static/images/ico_32px.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | .idea/ 4 | .DS_Store 5 | *.iml 6 | .coverage/ 7 | *.log 8 | *.snap 9 | parts/ 10 | *.db 11 | connect -------------------------------------------------------------------------------- /ssh/ssh_config: -------------------------------------------------------------------------------- 1 | Host * 2 | ForwardAgent yes 3 | SendEnv LANG LC_* 4 | StrictHostKeyChecking no 5 | 6 | Host github.com 7 | Hostname ssh.github.com 8 | Port 443 9 | 10 | Host gitlab.com 11 | Hostname altssh.gitlab.com 12 | Port 443 13 | -------------------------------------------------------------------------------- /webapp/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import "./scss/index.scss"; 4 | import App from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /webapp/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /webapp/src/components/constants.js: -------------------------------------------------------------------------------- 1 | const API_PREFIX = '/v1/' 2 | 3 | function getBaseURL() { 4 | return window.location.protocol + '//' + window.location.hostname + ':' + window.location.port + API_PREFIX; 5 | } 6 | 7 | let Constants = { 8 | baseUrl: getBaseURL(), 9 | } 10 | 11 | export default Constants 12 | -------------------------------------------------------------------------------- /webapp/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /webapp/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # RepoAdd the project 4 | npm run build 5 | 6 | # Create the static directory 7 | rm -rf ../static/ 8 | mkdir ../static/ 9 | cp build/* ../static/ 10 | cp -r build/static/css ../static/ 11 | cp -r build/static/js ../static/ 12 | cp -r build/static/images ../static/ 13 | 14 | # cleanup 15 | rm -rf ./build -------------------------------------------------------------------------------- /webapp/src/scss/_settings.scss: -------------------------------------------------------------------------------- 1 | // Vanilla settings: 2 | $grid-max-width: 1440 / 16 * 1rem; // express in rems for easier calculations 3 | 4 | $color-navigation-background: #666; 5 | $color-navigation-background--hover: darken($color-navigation-background, 3%); 6 | 7 | $breakpoint-navigation-threshold: 870px; 8 | $increase-font-size-on-larger-screens: false; 9 | 10 | $multi: 1; -------------------------------------------------------------------------------- /webapp/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Fabrica", 3 | "name": "Snap build factory", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.14.x 4 | dist: bionic 5 | addons: 6 | snaps: 7 | - name: snapcraft 8 | confinement: classic 9 | 10 | env: 11 | global: 12 | - LC_ALL=C.UTF-8 13 | - LANG=C.UTF-8 14 | - GO111MODULE=on 15 | 16 | install: 17 | - sudo sed -i '/^deb/s/$/ universe/' /etc/apt/sources.list 18 | - sudo apt-get update 19 | 20 | script: 21 | - sudo snapcraft --destructive-mode 22 | 23 | -------------------------------------------------------------------------------- /snap/hooks/connect-plug-mount-observe: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | if ! snapctl is-connected lxd; then 4 | echo "also need write access to lxd socket !!!" 5 | echo "please run 'snap connect ${SNAP_NAME}:lxd lxd:lxd" 6 | echo 7 | echo "keeping service disabled for the moment" 8 | exit 0 9 | fi 10 | 11 | if snapctl services ${SNAP_NAME}.init | grep -q inactive; then 12 | snapctl start --enable ${SNAP_NAME}.init 2>&1 || true 13 | fi 14 | -------------------------------------------------------------------------------- /webapp/public/static/images/square.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Row} from "@canonical/react-components"; 3 | import SystemMonitor from "./SystemMonitor"; 4 | 5 | function Footer(props) { 6 | return ( 7 | 16 | ); 17 | } 18 | 19 | export default Footer; -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Default settings 4 | const ( 5 | DefaultPort = "8000" 6 | ) 7 | 8 | // Settings defines the application configuration 9 | type Settings struct { 10 | Port string 11 | } 12 | 13 | // DefaultArgs checks the environment variables 14 | func DefaultArgs() *Settings { 15 | return &Settings{ 16 | Port: DefaultPort, 17 | } 18 | } 19 | 20 | // ReadParameters fetches the store config parameters 21 | func ReadParameters() *Settings { 22 | config := DefaultArgs() 23 | return config 24 | } 25 | -------------------------------------------------------------------------------- /webapp/src/scss/_patterns_navigation.scss: -------------------------------------------------------------------------------- 1 | //Local overrides to the navigation pattern 2 | @mixin maas-navigation { 3 | $breakpoint-hardware-menu: 1200px; 4 | 5 | .hardware-menu { 6 | display: none; 7 | 8 | @media (min-width: $breakpoint-navigation-threshold) and (max-width: $breakpoint-hardware-menu) { 9 | display: inherit; 10 | } 11 | } 12 | 13 | .u-hide--hardware-menu-threshold { 14 | @media (min-width: $breakpoint-navigation-threshold) and (max-width: $breakpoint-hardware-menu) { 15 | display: none; 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /webapp/src/components/DetailsCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Card, Row, Col} from "@canonical/react-components"; 3 | 4 | function DetailsCard(props) { 5 | return ( 6 | 7 | {props.fields.map(f => { 8 | return ( 9 | 10 | {f.label}: 11 | {f.value} 12 | 13 | ) 14 | })} 15 | 16 | ); 17 | } 18 | 19 | export default DetailsCard; -------------------------------------------------------------------------------- /webapp/src/components/RepoActions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {T} from "./Utils"; 3 | import {Button, Link} from "@canonical/react-components"; 4 | 5 | function RepoActions(props) { 6 | return ( 7 |
8 | 9 | 10 | {T("delete")} 11 | 12 |
13 | ); 14 | } 15 | 16 | export default RepoActions; -------------------------------------------------------------------------------- /webapp/public/static/images/show.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/src/components/BuildStatus.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Spinner} from "@canonical/react-components"; 3 | 4 | 5 | function getIcon(props) { 6 | if (props.status==='complete') { 7 | return 8 | } else if (props.status==='failed') { 9 | return 10 | } else { 11 | return 12 | } 13 | } 14 | 15 | 16 | function BuildStatus(props) { 17 | return ( 18 |
19 | {getIcon(props)} 20 |
21 | ); 22 | } 23 | 24 | export default BuildStatus; -------------------------------------------------------------------------------- /snap/hooks/connect-plug-lxd: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | 4 | if [ ! -f "$SNAP_DATA/ssh/ssh_host_rsa_key" ]; then 5 | echo "generate ssh host keys" 6 | mkdir -p $SNAP_DATA/ssh 7 | cp $SNAP/ssh/* $SNAP_DATA/ssh/ 8 | $SNAP/bin/fabrica -mode keygen 9 | fi 10 | 11 | if ! snapctl is-connected mount-observe; then 12 | echo "also need read access to disk size information !!!" 13 | echo "please run 'snap connect ${SNAP_NAME}:mount-observe" 14 | echo 15 | echo "keeping service disabled for the moment" 16 | exit 0 17 | fi 18 | 19 | if snapctl services ${SNAP_NAME}.init | grep -q inactive; then 20 | snapctl start --enable ${SNAP_NAME}.init 2>&1 || true 21 | fi 22 | 23 | -------------------------------------------------------------------------------- /webapp/public/static/images/check-square.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ogra1/fabrica 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/flosch/pongo2 v0.0.0-20200518135938-dfb43dbdc22a // indirect 7 | github.com/go-git/go-git/v5 v5.0.0 8 | github.com/gorilla/mux v1.7.4 9 | github.com/gorilla/websocket v1.4.2 // indirect 10 | github.com/lxc/lxd v0.0.0-20200518182231-ee995fa4e26b 11 | github.com/mattn/go-sqlite3 v2.0.3+incompatible 12 | github.com/rs/xid v1.2.1 13 | github.com/shirou/gopsutil v2.20.5+incompatible 14 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 15 | gopkg.in/macaroon-bakery.v2 v2.2.0 // indirect 16 | gopkg.in/robfig/cron.v2 v2.0.0-20150107220207-be2e0b0deed5 // indirect 17 | gopkg.in/yaml.v2 v2.3.0 18 | ) 19 | -------------------------------------------------------------------------------- /service/system/snapctl.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "log" 5 | "os/exec" 6 | "strings" 7 | ) 8 | 9 | // SnapCtlGet fetches a snap configuration option 10 | func (c *Service) SnapCtlGet(key string) (string, error) { 11 | out, err := exec.Command("snapctl", "get", key).Output() 12 | if err != nil { 13 | return "", err 14 | } 15 | return string(out), nil 16 | } 17 | 18 | // SnapCtlGetBool fetches a snap configuration option that is boolean 19 | func (c *Service) SnapCtlGetBool(key string) bool { 20 | value, err := c.SnapCtlGet(key) 21 | if err != nil { 22 | log.Println("Error calling snapctl:", err) 23 | return false 24 | } 25 | 26 | return strings.Contains(value, "true") 27 | } 28 | -------------------------------------------------------------------------------- /webapp/public/static/images/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | Fabrica - snap building factory 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /webapp/public/static/images/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /snap/hooks/configure: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | disable(){ 4 | snapctl stop --disable ${SNAP_NAME}.init 5 | } 6 | 7 | if ! snapctl is-connected mount-observe; then 8 | echo "need read access to disk size information !!!" 9 | echo "please run 'snap connect ${SNAP_NAME}:mount-observe" 10 | echo 11 | echo "disabling service for the moment" 12 | disable 13 | exit 0 14 | fi 15 | 16 | if ! snapctl is-connected lxd; then 17 | echo "need write access to lxd socket !!!" 18 | echo "please run 'snap connect ${SNAP_NAME}:lxd lxd:lxd" 19 | echo 20 | echo "disabling service for the moment" 21 | disable 22 | exit 0 23 | fi 24 | 25 | if snapctl services ${SNAP_NAME}.init | grep -q inactive; then 26 | snapctl start --enable ${SNAP_NAME}.init 2>&1 || true 27 | fi 28 | 29 | -------------------------------------------------------------------------------- /web/middleware.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Logger Handle logging for the web service 11 | func Logger(start time.Time, r *http.Request) { 12 | // Reduce noise in logs 13 | if r.Method == "GET" && r.RequestURI == "/v1/system" { 14 | return 15 | } 16 | if r.Method == "GET" && strings.HasPrefix(r.RequestURI, "/v1/builds/") { 17 | return 18 | } 19 | 20 | log.Printf( 21 | "%s\t%s\t%s", 22 | r.Method, 23 | r.RequestURI, 24 | time.Since(start), 25 | ) 26 | } 27 | 28 | // Middleware to pre-process web service requests 29 | func Middleware(inner http.Handler) http.Handler { 30 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | start := time.Now() 32 | 33 | // Log the request 34 | Logger(start, r) 35 | 36 | inner.ServeHTTP(w, r) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /service/writecloser/dbwrite.go: -------------------------------------------------------------------------------- 1 | package writecloser 2 | 3 | import "github.com/ogra1/fabrica/datastore" 4 | 5 | // DBWriteCloser writes log lines to the database 6 | type DBWriteCloser struct { 7 | BuildID string 8 | Datastore datastore.Datastore 9 | } 10 | 11 | // NewDBWriteCloser creates a new database write-closer 12 | func NewDBWriteCloser(buildID string, ds datastore.Datastore) *DBWriteCloser { 13 | return &DBWriteCloser{ 14 | BuildID: buildID, 15 | Datastore: ds, 16 | } 17 | } 18 | 19 | // Write writes a log message to the database 20 | func (dbw *DBWriteCloser) Write(b []byte) (int, error) { 21 | if err := dbw.Datastore.BuildLogCreate(dbw.BuildID, string(b)); err != nil { 22 | return 0, err 23 | } 24 | return len(b), nil 25 | } 26 | 27 | // Close is a noop to fulfill the interface 28 | func (dbw *DBWriteCloser) Close() error { 29 | // Noop 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /webapp/src/components/Utils.js: -------------------------------------------------------------------------------- 1 | import Messages from './Messages' 2 | 3 | export function T(message) { 4 | const msg = Messages[message] || message; 5 | return msg 6 | } 7 | 8 | export function formatError(data) { 9 | let message = T(data.code); 10 | if (data.message) { 11 | message += ': ' + data.message; 12 | } 13 | return message; 14 | } 15 | 16 | // URL is in the form: 17 | // /section 18 | // /section/sectionId 19 | // /section/sectionId/subsection 20 | export function parseRoute() { 21 | const parts = window.location.pathname.split('/') 22 | 23 | switch (parts.length) { 24 | case 2: 25 | return {section: parts[1]} 26 | case 3: 27 | return {section: parts[1], sectionId: parts[2]} 28 | case 4: 29 | return {section: parts[1], sectionId: parts[2], subsection: parts[3]} 30 | default: 31 | return {} 32 | } 33 | } -------------------------------------------------------------------------------- /web/lxd.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/ogra1/fabrica/domain" 5 | "net/http" 6 | ) 7 | 8 | var aliases = []string{"fabrica-focal", "fabrica-bionic", "fabrica-xenial"} 9 | 10 | // ImageAliases checks if the image aliases are available 11 | func (srv Web) ImageAliases(w http.ResponseWriter, r *http.Request) { 12 | responseList := []domain.SettingAvailable{} 13 | 14 | for _, a := range aliases { 15 | // Check if the alias is available 16 | err := srv.LXDSrv.GetImageAlias(a) 17 | 18 | responseList = append(responseList, domain.SettingAvailable{ 19 | Name: a, 20 | Available: err == nil, 21 | }) 22 | } 23 | 24 | formatRecordsResponse(responseList, w) 25 | } 26 | 27 | // CheckConnections checks the snap interfaces are connected 28 | func (srv Web) CheckConnections(w http.ResponseWriter, r *http.Request) { 29 | results := srv.LXDSrv.CheckConnections() 30 | 31 | formatRecordsResponse(results, w) 32 | } 33 | -------------------------------------------------------------------------------- /webapp/src/components/SystemMonitor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {MainTable} from "@canonical/react-components"; 3 | import {T} from "./Utils"; 4 | 5 | function SystemMonitor(props) { 6 | let data = [{columns: [ 7 | {content: props.system.cpu.toFixed(2) + '%', className: "u-align--right"}, 8 | {content: props.system.memory.toFixed(2) + '%', className: "u-align--right"}, 9 | {content: props.system.disk.toFixed(2) + '%', className: "u-align--right"}] 10 | }] 11 | return ( 12 | 21 | ); 22 | } 23 | 24 | export default SystemMonitor; -------------------------------------------------------------------------------- /web/index.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "html/template" 5 | "log" 6 | "net/http" 7 | "path/filepath" 8 | ) 9 | 10 | const ( 11 | docRoot = "./static" 12 | indexTemplate = "index.html" 13 | ) 14 | 15 | // Index is the front page of the web application 16 | func (srv Web) Index(w http.ResponseWriter, r *http.Request) { 17 | t, err := srv.templates(indexTemplate) 18 | if err != nil { 19 | log.Printf("Error loading the application template: %v\n", err) 20 | http.Error(w, err.Error(), http.StatusInternalServerError) 21 | return 22 | } 23 | 24 | err = t.Execute(w, nil) 25 | if err != nil { 26 | http.Error(w, err.Error(), http.StatusInternalServerError) 27 | } 28 | } 29 | 30 | func (srv Web) templates(name string) (*template.Template, error) { 31 | // Parse the templates 32 | p := filepath.Join(docRoot, name) 33 | t, err := template.ParseFiles(p) 34 | if err != nil { 35 | log.Printf("Error loading the application template: %v\n", err) 36 | } 37 | return t, err 38 | } 39 | -------------------------------------------------------------------------------- /web/system.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/ogra1/fabrica/domain" 5 | "net/http" 6 | ) 7 | 8 | // SystemResources monitors the system resources 9 | func (srv Web) SystemResources(w http.ResponseWriter, r *http.Request) { 10 | cpu, err := srv.SystemSrv.CPU() 11 | if err != nil { 12 | formatStandardResponse("system", err.Error(), w) 13 | return 14 | } 15 | mem, err := srv.SystemSrv.Memory() 16 | if err != nil { 17 | formatStandardResponse("system", err.Error(), w) 18 | return 19 | } 20 | disk, err := srv.SystemSrv.Disk() 21 | if err != nil { 22 | formatStandardResponse("system", err.Error(), w) 23 | return 24 | } 25 | 26 | rec := domain.SystemResources{ 27 | CPU: cpu, 28 | Memory: mem, 29 | Disk: disk, 30 | } 31 | formatRecordResponse(rec, w) 32 | } 33 | 34 | // Environment gets specific environment values 35 | func (srv Web) Environment(w http.ResponseWriter, r *http.Request) { 36 | env := srv.SystemSrv.Environment() 37 | formatRecordResponse(env, w) 38 | } 39 | -------------------------------------------------------------------------------- /webapp/src/components/BuildActions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {T} from "./Utils"; 3 | import {Link} from "@canonical/react-components" 4 | 5 | function BuildActions(props) { 6 | return ( 7 |
8 | 9 | {T("view")}/ 10 | 11 | {props.download ? 12 | 13 | {T("download")}/ 14 | 15 | : 16 | '' 17 | } 18 | 19 | {T("delete")} 20 | 21 |
22 | ); 23 | } 24 | 25 | export default BuildActions; -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webapp", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@canonical/react-components": "^0.7.0", 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.3.2", 9 | "@testing-library/user-event": "^7.1.2", 10 | "axios": "^0.21.1", 11 | "moment": "^2.25.3", 12 | "node-sass": "^4.14.1", 13 | "react": "^16.13.1", 14 | "react-dom": "^16.13.1", 15 | "react-scripts": "3.4.1" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /service/repo/build_actions.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "github.com/ogra1/fabrica/domain" 5 | "os" 6 | "path" 7 | ) 8 | 9 | // List returns a list of the builds that have been requested 10 | func (bld *BuildService) List() ([]domain.Build, error) { 11 | return bld.Datastore.BuildList() 12 | } 13 | 14 | // BuildGet returns a build with its logs 15 | func (bld *BuildService) BuildGet(id string) (domain.Build, error) { 16 | return bld.Datastore.BuildGet(id) 17 | } 18 | 19 | // BuildDelete deletes a build with its logs and snap 20 | func (bld *BuildService) BuildDelete(id string) error { 21 | // Get the path to the built file and remove it 22 | build, err := bld.Datastore.BuildGet(id) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | // Remove the stored files 28 | if build.Download != "" { 29 | dir := path.Dir(build.Download) 30 | os.RemoveAll(dir) 31 | } 32 | 33 | // Stop and delete the running container 34 | if build.Container != "" { 35 | bld.LXDSrv.StopAndDeleteContainer(build.Container) 36 | } 37 | 38 | // Remove the database records for the build and logs 39 | return bld.Datastore.BuildDelete(id) 40 | } 41 | -------------------------------------------------------------------------------- /web/buildlog.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path" 9 | ) 10 | 11 | // BuildLog fetches a build with its logs 12 | func (srv Web) BuildLog(w http.ResponseWriter, r *http.Request) { 13 | vars := mux.Vars(r) 14 | bld, err := srv.BuildSrv.BuildGet(vars["id"]) 15 | if err != nil { 16 | formatStandardResponse("logs", err.Error(), w) 17 | return 18 | } 19 | 20 | formatRecordResponse(bld, w) 21 | } 22 | 23 | // BuildDownload fetches the built snap 24 | func (srv Web) BuildDownload(w http.ResponseWriter, r *http.Request) { 25 | vars := mux.Vars(r) 26 | bld, err := srv.BuildSrv.BuildGet(vars["id"]) 27 | if err != nil { 28 | formatStandardResponse("logs", err.Error(), w) 29 | return 30 | } 31 | 32 | // Get the filename of the download 33 | filename := path.Base(bld.Download) 34 | 35 | w.Header().Set("Content-Disposition", "attachment; filename="+filename) 36 | w.Header().Set("Content-Type", r.Header.Get("Content-Type")) 37 | 38 | download, err := os.Open(bld.Download) 39 | if err != nil { 40 | formatStandardResponse("download", err.Error(), w) 41 | return 42 | } 43 | defer download.Close() 44 | 45 | io.Copy(w, download) 46 | } 47 | -------------------------------------------------------------------------------- /service/writecloser/flagwrite.go: -------------------------------------------------------------------------------- 1 | package writecloser 2 | 3 | import ( 4 | "github.com/ogra1/fabrica/datastore" 5 | "log" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | // FlagWriteCloser is a writer-closer that looks for a specific message 11 | type FlagWriteCloser struct { 12 | lock sync.RWMutex 13 | Contains string 14 | Datastore datastore.Datastore 15 | found bool 16 | } 17 | 18 | // NewFlagWriteCloser creates a new flag write-closer 19 | func NewFlagWriteCloser(contains string) *FlagWriteCloser { 20 | return &FlagWriteCloser{ 21 | Contains: contains, 22 | } 23 | } 24 | 25 | // Write writes a log message to the database 26 | func (wc *FlagWriteCloser) Write(b []byte) (int, error) { 27 | wc.lock.Lock() 28 | defer wc.lock.Unlock() 29 | 30 | log.Println(string(b)) 31 | 32 | if strings.Contains(string(b), wc.Contains) { 33 | wc.found = true 34 | } 35 | return len(b), nil 36 | } 37 | 38 | // Close is a noop to fulfill the interface 39 | func (wc *FlagWriteCloser) Close() error { 40 | // Noop 41 | return nil 42 | } 43 | 44 | // Found identifies if the string has been found 45 | func (wc *FlagWriteCloser) Found() bool { 46 | wc.lock.RLock() 47 | defer wc.lock.RUnlock() 48 | 49 | return wc.found 50 | } 51 | -------------------------------------------------------------------------------- /datastore/datastore.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import "github.com/ogra1/fabrica/domain" 4 | 5 | // Datastore interface for the database logic 6 | type Datastore interface { 7 | BuildList() ([]domain.Build, error) 8 | BuildCreate(name, repo, branch string) (string, error) 9 | BuildUpdate(id, status string, duration int) error 10 | BuildUpdateDownload(id, download string) error 11 | BuildUpdateContainer(id, container string) error 12 | BuildGet(id string) (domain.Build, error) 13 | BuildDelete(id string) error 14 | BuildListForRepo(name, branch string) ([]domain.Build, error) 15 | 16 | BuildLogCreate(id, message string) error 17 | BuildLogList(id string) ([]domain.BuildLog, error) 18 | 19 | RepoCreate(name, repo, branch, keyID string) (string, error) 20 | RepoGet(id string) (domain.Repo, error) 21 | RepoList(watch bool) ([]domain.Repo, error) 22 | RepoUpdateHash(id, hash string) error 23 | RepoDelete(id string) error 24 | 25 | KeysCreate(name, data, password string) (string, error) 26 | KeysGet(id string) (domain.Key, error) 27 | KeysList() ([]domain.Key, error) 28 | KeysDelete(id string) error 29 | 30 | SettingsCreate(key, name, data string) (string, error) 31 | SettingsGet(key, name string) (domain.ConfigSetting, error) 32 | } 33 | -------------------------------------------------------------------------------- /webapp/src/components/Settings.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {formatError, T} from "./Utils"; 3 | import {Row} from '@canonical/react-components' 4 | import api from "./api"; 5 | import KeysList from "./KeysList"; 6 | 7 | class Settings extends Component { 8 | constructor(props) { 9 | super(props) 10 | this.state = { 11 | keys: [{id:'a123', name: 'first', created:'2020-06-09T19:01:34Z'}] 12 | } 13 | } 14 | 15 | componentDidMount() { 16 | this.getDataKeys() 17 | } 18 | 19 | getDataKeys() { 20 | api.keysList().then(response => { 21 | this.setState({keys: response.data.records}) 22 | }) 23 | .catch(e => { 24 | console.log(formatError(e.response.data)) 25 | this.setState({error: formatError(e.response.data), message: ''}); 26 | }) 27 | } 28 | 29 | handleCreate = () => { 30 | this.getDataKeys() 31 | } 32 | 33 | render() { 34 | return ( 35 | 36 |

{T('settings')}

37 | 38 | 39 |
40 | ); 41 | } 42 | } 43 | 44 | export default Settings; -------------------------------------------------------------------------------- /service/key/key.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "github.com/ogra1/fabrica/datastore" 5 | "github.com/ogra1/fabrica/domain" 6 | ) 7 | 8 | // Srv is the interface for the ssh key service 9 | type Srv interface { 10 | Create(name, data, password string) (string, error) 11 | Get(id string) (domain.Key, error) 12 | List() ([]domain.Key, error) 13 | Delete(id string) error 14 | } 15 | 16 | // Service implements the ssh key service 17 | type Service struct { 18 | Datastore datastore.Datastore 19 | } 20 | 21 | // NewKeyService creates a new ssh key service 22 | func NewKeyService(ds datastore.Datastore) *Service { 23 | return &Service{ 24 | Datastore: ds, 25 | } 26 | } 27 | 28 | // Create stores a new ssh key 29 | func (ks *Service) Create(name, data, password string) (string, error) { 30 | return ks.Datastore.KeysCreate(name, data, password) 31 | } 32 | 33 | // Get fetches an existing ssh key 34 | func (ks *Service) Get(id string) (domain.Key, error) { 35 | return ks.Datastore.KeysGet(id) 36 | } 37 | 38 | // List fetches existing ssh keys 39 | func (ks *Service) List() ([]domain.Key, error) { 40 | return ks.Datastore.KeysList() 41 | } 42 | 43 | // Delete removes an existing ssh key 44 | func (ks *Service) Delete(id string) error { 45 | return ks.Datastore.KeysDelete(id) 46 | } 47 | -------------------------------------------------------------------------------- /webapp/src/components/ImageList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {T} from "./Utils"; 3 | import {Row, Notification} from "@canonical/react-components"; 4 | 5 | function ImageList(props) { 6 | return ( 7 |
8 | 9 | 10 | {props.images.map(img => { 11 | return ( 12 | 13 | 14 | 15 | 22 | 23 |
{img.name} 16 | {img.available ? 17 | 18 | : 19 | 20 | } 21 |
24 | ) 25 | })} 26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export default ImageList; -------------------------------------------------------------------------------- /webapp/src/components/ConnectionList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {T} from "./Utils"; 3 | import {Row, Notification} from "@canonical/react-components"; 4 | 5 | function ConnectionList(props) { 6 | return ( 7 |
8 | 9 | 10 | {props.connections.map(img => { 11 | return ( 12 | 13 | 14 | 15 | 22 | 23 |
{img.name} 16 | {img.available ? 17 | 18 | : 19 | 20 | } 21 |
24 | ) 25 | })} 26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export default ConnectionList; -------------------------------------------------------------------------------- /webapp/src/components/RepoAdd.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Button, Card, Form, Input, Row, Select} from "@canonical/react-components"; 3 | import {T} from "./Utils"; 4 | 5 | class RepoAdd extends Component { 6 | render() { 7 | let data = this.props.keys.map(k => { 8 | return { 9 | label: k.name, value: k.id 10 | } 11 | }) 12 | data.unshift({label:'', value:''}) 13 | 14 | return ( 15 | 16 | 17 |
18 | 19 | 20 | 34 | 35 | 36 | 37 | 38 |
39 |
40 |
41 | ); 42 | } 43 | } 44 | 45 | export default KeysAdd; -------------------------------------------------------------------------------- /web/build.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/gorilla/mux" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | type buildRequest struct { 11 | Repo string `json:"repo"` 12 | Branch string `json:"branch"` 13 | KeyID string `json:"keyId"` 14 | } 15 | 16 | // Build initiates a build 17 | func (srv Web) Build(w http.ResponseWriter, r *http.Request) { 18 | req := srv.decodeBuildRequest(w, r) 19 | if req == nil { 20 | return 21 | } 22 | 23 | // Start the build for the repo with this ID 24 | buildID, err := srv.BuildSrv.Build(req.Repo) 25 | if err != nil { 26 | formatStandardResponse("build", err.Error(), w) 27 | return 28 | } 29 | 30 | formatStandardResponse("", buildID, w) 31 | } 32 | 33 | // BuildList lists the build requests 34 | func (srv Web) BuildList(w http.ResponseWriter, r *http.Request) { 35 | builds, err := srv.BuildSrv.List() 36 | if err != nil { 37 | formatStandardResponse("list", err.Error(), w) 38 | return 39 | } 40 | 41 | formatRecordsResponse(builds, w) 42 | } 43 | 44 | // BuildDelete deletes a build with its logs 45 | func (srv Web) BuildDelete(w http.ResponseWriter, r *http.Request) { 46 | vars := mux.Vars(r) 47 | err := srv.BuildSrv.BuildDelete(vars["id"]) 48 | if err != nil { 49 | formatStandardResponse("logs", err.Error(), w) 50 | return 51 | } 52 | 53 | formatStandardResponse("", "", w) 54 | } 55 | 56 | func (srv Web) decodeBuildRequest(w http.ResponseWriter, r *http.Request) *buildRequest { 57 | // Decode the JSON body 58 | req := buildRequest{} 59 | err := json.NewDecoder(r.Body).Decode(&req) 60 | switch { 61 | // Check we have some data 62 | case err == io.EOF: 63 | formatStandardResponse("data", "No request data supplied.", w) 64 | return nil 65 | // Check for parsing errors 66 | case err != nil: 67 | formatStandardResponse("decode-json", err.Error(), w) 68 | return nil 69 | } 70 | return &req 71 | } 72 | -------------------------------------------------------------------------------- /datastore/sqlite/database.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | _ "github.com/mattn/go-sqlite3" // driver 6 | "log" 7 | "os" 8 | "path" 9 | ) 10 | 11 | const ( 12 | driver = "sqlite3" 13 | dataSource = "fabrica.db" 14 | ) 15 | 16 | // DB local database with our custom methods. 17 | type DB struct { 18 | *sql.DB 19 | } 20 | 21 | // NewDatabase returns an open database connection 22 | func NewDatabase() (*DB, error) { 23 | // Open the database connection 24 | log.Println("Open database:", GetPath(dataSource)) 25 | db, err := sql.Open(driver, GetPath(dataSource)) 26 | if err != nil { 27 | log.Fatalf("Error opening the database: %v\n", err) 28 | } 29 | 30 | // Check that we have a valid database connection 31 | err = db.Ping() 32 | if err != nil { 33 | log.Fatalf("Error accessing the database: %v\n", err) 34 | } 35 | 36 | store := &DB{db} 37 | store.CreateTables() 38 | 39 | return store, nil 40 | } 41 | 42 | // CreateTables creates the database tables 43 | func (db *DB) CreateTables() error { 44 | if _, err := db.Exec(createRepoTableSQL); err != nil { 45 | return err 46 | } 47 | if _, err := db.Exec(createBuildTableSQL); err != nil { 48 | return err 49 | } 50 | if _, err := db.Exec(createBuildLogTableSQL); err != nil { 51 | return err 52 | } 53 | if _, err := db.Exec(createSettingsTableSQL); err != nil { 54 | return err 55 | } 56 | if _, err := db.Exec(createKeysTableSQL); err != nil { 57 | return err 58 | } 59 | _, _ = db.Exec(alterRepoTableSQL) 60 | _, _ = db.Exec(alterBuildTableSQL) 61 | _, _ = db.Exec(alterBuildTableSQLcontainer) 62 | _, _ = db.Exec(alterRepoTableKeySQL) 63 | return nil 64 | } 65 | 66 | // GetPath returns the full path to the data file 67 | func GetPath(filename string) string { 68 | if len(os.Getenv("SNAP_DATA")) > 0 { 69 | return path.Join(os.Getenv("SNAP_DATA"), "../current", filename) 70 | } 71 | return filename 72 | } 73 | -------------------------------------------------------------------------------- /webapp/src/components/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import constants from './constants' 3 | 4 | let service = { 5 | build: (repo, cancelCallback) => { 6 | return axios.post(constants.baseUrl + 'build', {repo: repo}); 7 | }, 8 | 9 | buildList: (cancelCallback) => { 10 | return axios.get(constants.baseUrl + 'builds'); 11 | }, 12 | 13 | buildGet: (buildId, cancelCallback) => { 14 | return axios.get(constants.baseUrl + 'builds/' + buildId); 15 | }, 16 | 17 | buildDelete: (buildId, cancelCallback) => { 18 | return axios.delete(constants.baseUrl + 'builds/' + buildId); 19 | }, 20 | 21 | repoList: (cancelCallback) => { 22 | return axios.get(constants.baseUrl + 'repos'); 23 | }, 24 | 25 | repoCreate: (repo, branch, keyId, cancelCallback) => { 26 | return axios.post(constants.baseUrl + 'repos', {repo: repo, branch: branch, keyId: keyId}); 27 | }, 28 | 29 | repoDelete: (repoId, deleteBuilds, cancelCallback) => { 30 | return axios.post(constants.baseUrl + 'repos/delete', {id: repoId, deleteBuilds: deleteBuilds}); 31 | }, 32 | 33 | imageList: (cancelCallback) => { 34 | return axios.get(constants.baseUrl + 'check/images'); 35 | }, 36 | 37 | connectionList: (cancelCallback) => { 38 | return axios.get(constants.baseUrl + 'check/connections'); 39 | }, 40 | 41 | systemMonitor: (cancelCallback) => { 42 | return axios.get(constants.baseUrl + 'system'); 43 | }, 44 | 45 | systemEnvironment: (cancelCallback) => { 46 | return axios.get(constants.baseUrl + 'system/environment'); 47 | }, 48 | 49 | keysList: (cancelCallback) => { 50 | return axios.get(constants.baseUrl + 'keys'); 51 | }, 52 | 53 | keysCreate: (key, cancelCallback) => { 54 | return axios.post(constants.baseUrl + 'keys', key); 55 | }, 56 | } 57 | 58 | export default service -------------------------------------------------------------------------------- /web/repo.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | type repoDeleteRequest struct { 10 | RepoID string `json:"id"` 11 | DeleteBuilds bool `json:"deleteBuilds"` 12 | } 13 | 14 | // RepoCreate creates a repository 15 | func (srv Web) RepoCreate(w http.ResponseWriter, r *http.Request) { 16 | req := srv.decodeBuildRequest(w, r) 17 | if req == nil { 18 | return 19 | } 20 | 21 | // Create the repo 22 | repoID, err := srv.BuildSrv.RepoCreate(req.Repo, req.Branch, req.KeyID) 23 | if err != nil { 24 | formatStandardResponse("repo", err.Error(), w) 25 | return 26 | } 27 | 28 | formatStandardResponse("", repoID, w) 29 | } 30 | 31 | // RepoList lists the watched repos 32 | func (srv Web) RepoList(w http.ResponseWriter, r *http.Request) { 33 | records, err := srv.BuildSrv.RepoList(false) 34 | if err != nil { 35 | formatStandardResponse("list", err.Error(), w) 36 | return 37 | } 38 | 39 | formatRecordsResponse(records, w) 40 | } 41 | 42 | // RepoDelete remove a repo and, optionally, its builds 43 | func (srv Web) RepoDelete(w http.ResponseWriter, r *http.Request) { 44 | req := srv.decodeRepoDeleteRequest(w, r) 45 | if req == nil { 46 | return 47 | } 48 | 49 | // Delete the repo 50 | if err := srv.BuildSrv.RepoDelete(req.RepoID, req.DeleteBuilds); err != nil { 51 | formatStandardResponse("repo", err.Error(), w) 52 | return 53 | } 54 | 55 | formatStandardResponse("", "", w) 56 | } 57 | 58 | func (srv Web) decodeRepoDeleteRequest(w http.ResponseWriter, r *http.Request) *repoDeleteRequest { 59 | // Decode the JSON body 60 | req := repoDeleteRequest{} 61 | err := json.NewDecoder(r.Body).Decode(&req) 62 | switch { 63 | // Check we have some data 64 | case err == io.EOF: 65 | formatStandardResponse("data", "No request data supplied.", w) 66 | return nil 67 | // Check for parsing errors 68 | case err != nil: 69 | formatStandardResponse("decode-json", err.Error(), w) 70 | return nil 71 | } 72 | return &req 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fabrica - snap build factory 2 | 3 | Fabrica is a web service to be run on an lxd appliance. It spawns a 4 | web application that allows you to point to cloneable git trees, initializes 5 | lxd and builds snap packages of the provided source trees. 6 | 7 | Once the snap is installed, it needs its interfaces to be connected: 8 | ```bash 9 | snap install lxd 10 | sudo lxd init # hit enter for all questions 11 | 12 | sudo snap install fabrica 13 | 14 | sudo snap connect fabrica:lxd lxd:lxd 15 | sudo snap connect fabrica:mount-observe :mount-observe 16 | sudo snap connect fabrica:system-observe :system-observe 17 | sudo snap connect fabrica:ssh-keys :ssh-keys 18 | ``` 19 | 20 | ## Options 21 | Fabrica has a configuration option that may be useful for a development environment: 22 | - `debug=true`: (default false) if a build fails, the LXD container is retained for debugging 23 | 24 | Options can be set using a `snap set` command e.g. 25 | ``` 26 | sudo snap set fabrica debug=true 27 | ``` 28 | 29 | If the `debug` option is used, the container will need to be deleted manually to 30 | recover disk space. 31 | 32 | ## Development Environment 33 | The build needs Go 13.* and npm installed. 34 | 35 | ### Building the web pages 36 | The web pages use [create-react-app](https://github.com/facebook/create-react-app) 37 | which needs an up-to-date version of Node. 38 | ``` 39 | cd webapp 40 | npm install 41 | ./build.sh 42 | ``` 43 | 44 | ### Building the application 45 | The application is packaged as a [snap](https://snapcraft.io/docs) and can be 46 | built using the `snapcraft` command. The [snapcraft.yaml](snap/snapcraft.yaml) 47 | is the source for building the application and the name of the snap needs to be 48 | modified in that file. 49 | 50 | For testing purposes, it can also be run via: 51 | ``` 52 | go run fabrica.go 53 | ``` 54 | 55 | ## API 56 | The application provides an [open API](docs/API.md) that allows it to be integrated with other 57 | services and scripts. 58 | 59 | ## Credits 60 | Icons provided by [Font Awesome](https://fontawesome.com/) 61 | -------------------------------------------------------------------------------- /domain/entity.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "time" 4 | 5 | // Repo is the requested repository to watch 6 | type Repo struct { 7 | ID string `json:"id"` 8 | Name string `json:"name"` 9 | Repo string `json:"repo"` 10 | Branch string `json:"branch"` 11 | KeyID string `json:"keyId"` 12 | LastCommit string `json:"hash"` 13 | Created time.Time `json:"created"` 14 | Modified time.Time `json:"modified"` 15 | } 16 | 17 | // Build is the requested build 18 | type Build struct { 19 | ID string `json:"id"` 20 | Name string `json:"name"` 21 | Repo string `json:"repo"` 22 | Branch string `json:"branch"` 23 | Status string `json:"status"` 24 | Download string `json:"download"` 25 | Container string `json:"container"` 26 | Duration int `json:"duration"` 27 | Created time.Time `json:"created"` 28 | Logs []BuildLog `json:"logs,omitempty"` 29 | } 30 | 31 | // BuildLog is the log for a build 32 | type BuildLog struct { 33 | ID string `json:"id"` 34 | BuildID string `json:"buildId"` 35 | Message string `json:"message"` 36 | Created time.Time `json:"created"` 37 | } 38 | 39 | // SettingAvailable is a generic response for a setting 40 | type SettingAvailable struct { 41 | Name string `json:"name"` 42 | Available bool `json:"available"` 43 | } 44 | 45 | // SystemResources is the monitor of system resources 46 | type SystemResources struct { 47 | CPU float64 `json:"cpu"` 48 | Memory float64 `json:"memory"` 49 | Disk float64 `json:"disk"` 50 | } 51 | 52 | // ConfigSetting is a stored config setting 53 | type ConfigSetting struct { 54 | ID string `json:"id"` 55 | Key string `json:"key"` 56 | Name string `json:"name"` 57 | Data string `json:"data"` 58 | } 59 | 60 | // Key is an ssh key for a repo 61 | type Key struct { 62 | ID string `json:"id"` 63 | Name string `json:"name"` 64 | Data string `json:"data,omitempty"` 65 | Password string `json:"password,omitempty"` 66 | Created time.Time `json:"created"` 67 | } 68 | -------------------------------------------------------------------------------- /web/response.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | // JSONHeader is the header for JSON responses 10 | const JSONHeader = "application/json; charset=UTF-8" 11 | 12 | // StandardResponse is the JSON response from an API method, indicating success or failure. 13 | type StandardResponse struct { 14 | Code string `json:"code"` 15 | Message string `json:"message"` 16 | } 17 | 18 | // RecordResponse the JSON response from a get call 19 | type RecordResponse struct { 20 | StandardResponse 21 | Record interface{} `json:"record"` 22 | } 23 | 24 | // RecordsResponse the JSON response from a list call 25 | type RecordsResponse struct { 26 | StandardResponse 27 | Records interface{} `json:"records"` 28 | } 29 | 30 | // formatStandardResponse returns a JSON response from an API method, indicating success or failure 31 | func formatStandardResponse(code, message string, w http.ResponseWriter) { 32 | w.Header().Set("Content-Type", JSONHeader) 33 | response := StandardResponse{Code: code, Message: message} 34 | 35 | if len(code) > 0 { 36 | w.WriteHeader(http.StatusBadRequest) 37 | } 38 | 39 | // Encode the response as JSON 40 | encodeResponse(w, response) 41 | } 42 | 43 | // formatRecordResponse returns a JSON response from an api call 44 | func formatRecordResponse(record interface{}, w http.ResponseWriter) { 45 | w.Header().Set("Content-Type", JSONHeader) 46 | response := RecordResponse{StandardResponse{}, record} 47 | 48 | // Encode the response as JSON 49 | encodeResponse(w, response) 50 | } 51 | 52 | // formatRecordsResponse returns a JSON response from an api call 53 | func formatRecordsResponse(records interface{}, w http.ResponseWriter) { 54 | w.Header().Set("Content-Type", JSONHeader) 55 | response := RecordsResponse{StandardResponse{}, records} 56 | 57 | // Encode the response as JSON 58 | encodeResponse(w, response) 59 | } 60 | 61 | func encodeResponse(w http.ResponseWriter, response interface{}) { 62 | // Encode the response as JSON 63 | if err := json.NewEncoder(w).Encode(response); err != nil { 64 | log.Println("Error forming the response:", err) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /datastore/sqlite/settings.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/ogra1/fabrica/domain" 6 | "github.com/rs/xid" 7 | "log" 8 | ) 9 | 10 | const ( 11 | secretKeyName = "secret" 12 | keyLength = 64 13 | ) 14 | 15 | const createSettingsTableSQL string = ` 16 | CREATE TABLE IF NOT EXISTS settings ( 17 | id varchar(200) primary key not null, 18 | key varchar(200) not null, 19 | name varchar(200) not null, 20 | data text default '', 21 | created timestamp default current_timestamp, 22 | modified timestamp default current_timestamp 23 | ) 24 | ` 25 | const addSettingSQL = ` 26 | INSERT INTO settings (id, key, name, data) VALUES ($1, $2, $3, $4) 27 | ` 28 | const getSettingSQL = ` 29 | SELECT id, key, name, data 30 | FROM settings 31 | WHERE key=$1 and name=$2 32 | ` 33 | 34 | // SettingsCreate stores a new config setting 35 | func (db *DB) SettingsCreate(key, name, data string) (string, error) { 36 | id := xid.New() 37 | _, err := db.Exec(addSettingSQL, id.String(), key, name, data) 38 | return id.String(), err 39 | } 40 | 41 | // SettingsGet fetches an existing config setting 42 | func (db *DB) SettingsGet(key, name string) (domain.ConfigSetting, error) { 43 | r := domain.ConfigSetting{} 44 | err := db.QueryRow(getSettingSQL, key, name).Scan(&r.ID, &r.Key, &r.Name, &r.Data) 45 | switch { 46 | case err == sql.ErrNoRows: 47 | return r, err 48 | case err != nil: 49 | log.Printf("Error retrieving database repo: %v\n", err) 50 | return r, err 51 | } 52 | return r, nil 53 | } 54 | 55 | func (db *DB) secretKey() (string, error) { 56 | // Get the secret key from the database and return it 57 | key, err := db.SettingsGet(secretKeyName, secretKeyName) 58 | if err == nil { 59 | return key.Data, nil 60 | } 61 | 62 | // Cannot find a secret, so generate one 63 | secret, err := generateSecret() 64 | if err != nil { 65 | return "", err 66 | } 67 | 68 | // Store the secret 69 | if _, err := db.SettingsCreate(secretKeyName, secretKeyName, secret); err != nil { 70 | return "", err 71 | } 72 | return secret, nil 73 | } 74 | -------------------------------------------------------------------------------- /service/system/system.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "github.com/ogra1/fabrica/datastore" 5 | "github.com/shirou/gopsutil/cpu" 6 | "github.com/shirou/gopsutil/disk" 7 | "github.com/shirou/gopsutil/mem" 8 | "log" 9 | "os" 10 | ) 11 | 12 | // Srv interface for system resources 13 | type Srv interface { 14 | CPU() (float64, error) 15 | Memory() (float64, error) 16 | Disk() (float64, error) 17 | Environment() map[string]string 18 | 19 | SnapCtlGet(key string) (string, error) 20 | SnapCtlGetBool(key string) bool 21 | } 22 | 23 | const ( 24 | snapData = "SNAP_DATA" 25 | snapVersion = "SNAP_VERSION" 26 | snapArch = "SNAP_ARCH" 27 | ) 28 | 29 | // Service implements a system service 30 | type Service struct { 31 | Datastore datastore.Datastore 32 | } 33 | 34 | // NewSystemService creates a new system service 35 | func NewSystemService(ds datastore.Datastore) *Service { 36 | return &Service{ 37 | Datastore: ds, 38 | } 39 | } 40 | 41 | // CPU returns the current CPU usage 42 | func (c *Service) CPU() (float64, error) { 43 | vv, err := cpu.Percent(0, false) 44 | if err != nil { 45 | log.Printf("Error getting cpu usage: %v\n", err) 46 | return 0, err 47 | } 48 | 49 | var total float64 50 | if len(vv) > 0 { 51 | total = vv[0] 52 | } 53 | 54 | return total, nil 55 | } 56 | 57 | // Memory returns the current memory usage 58 | func (c *Service) Memory() (float64, error) { 59 | v, err := mem.VirtualMemory() 60 | if err != nil { 61 | log.Printf("Error getting memory usage: %v\n", err) 62 | return 0, err 63 | } 64 | 65 | return v.UsedPercent, nil 66 | } 67 | 68 | // Disk returns the current disk usage 69 | func (c *Service) Disk() (float64, error) { 70 | // Check the disk space of the host FS not the snap 71 | v, err := disk.Usage(os.Getenv(snapData)) 72 | if err != nil { 73 | log.Printf("Error getting disk usage: %v\n", err) 74 | return 0, err 75 | } 76 | 77 | return v.UsedPercent, nil 78 | } 79 | 80 | // Environment gets a set of the environment variables 81 | func (c *Service) Environment() map[string]string { 82 | return map[string]string{ 83 | "version": os.Getenv(snapVersion), 84 | "arch": os.Getenv(snapArch), 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /webapp/src/App.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import Header from "./components/Header"; 3 | import Home from "./components/Home"; 4 | import {formatError, parseRoute} from "./components/Utils"; 5 | import BuildLog from "./components/BuildLog"; 6 | import Footer from "./components/Footer"; 7 | import api from "./components/api"; 8 | import Settings from "./components/Settings"; 9 | 10 | class App extends Component { 11 | constructor(props) { 12 | super(props) 13 | this.state = { 14 | system: {cpu:0, memory:0, disk:0}, 15 | environment: {version:'0.1.0', arch:'arm64'}, 16 | } 17 | } 18 | 19 | componentDidMount() { 20 | this.getSystemEnvironment() 21 | this.getSystemMonitor() 22 | } 23 | 24 | poll = () => { 25 | // Polls every 2s 26 | setTimeout(this.getSystemMonitor.bind(this), 2000); 27 | } 28 | 29 | getSystemEnvironment() { 30 | api.systemEnvironment().then(response => { 31 | this.setState({environment: response.data.record}) 32 | }) 33 | .catch(e => { 34 | console.log(formatError(e.response.data)) 35 | this.setState({error: formatError(e.response.data), message: ''}); 36 | }) 37 | } 38 | 39 | getSystemMonitor() { 40 | api.systemMonitor().then(response => { 41 | this.setState({system: response.data.record}) 42 | }) 43 | .catch(e => { 44 | console.log(formatError(e.response.data)) 45 | this.setState({error: formatError(e.response.data), message: ''}); 46 | }) 47 | .finally( ()=> { 48 | this.poll() 49 | }) 50 | } 51 | 52 | render() { 53 | const r = parseRoute() 54 | 55 | return ( 56 |
57 |
58 | 59 | {r.section===''? : ''} 60 | {r.section==='builds'? : ''} 61 | {r.section==='settings'? : ''} 62 | 63 |
64 |
65 | ); 66 | } 67 | } 68 | 69 | export default App; 70 | -------------------------------------------------------------------------------- /webapp/src/scss/index.scss: -------------------------------------------------------------------------------- 1 | // Import settings 2 | @import "./settings"; 3 | 4 | // Import and include base Vanilla 5 | @import "node_modules/vanilla-framework/scss/build"; 6 | 7 | // Import MAAS overrides of Vanilla patterns 8 | @import "./vanilla-overrides"; 9 | 10 | 11 | // Import and include MAAS global styles 12 | 13 | @import "./patterns_navigation"; 14 | //@include maas-buttons; 15 | //@include maas-double-row; 16 | //@include maas-footer; 17 | //@include maas-forms; 18 | //@include maas-icons; 19 | //@include maas-links; 20 | @include maas-navigation; 21 | //@include maas-notifications; 22 | //@include maas-table-actions; 23 | //@include maas-tables; 24 | //@include maas-tabs; 25 | //@include maas-utilities; 26 | 27 | .field_value { 28 | color: #666666; 29 | } 30 | 31 | .environment { 32 | color: #cacaca; 33 | } 34 | 35 | pre { 36 | white-space: pre-wrap; /* css-3 */ 37 | white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ 38 | white-space: -o-pre-wrap; /* Opera 7 */ 39 | word-wrap: break-word; /* Internet Explorer 5.5+ */ 40 | } 41 | 42 | .log { 43 | background-color: #333333; 44 | color: #eeeeee; 45 | font-family: "Ubuntu Mono", Menlo, Consolas, monospace; 46 | padding: 0.5em; 47 | margin-bottom: 0.5em; 48 | } 49 | 50 | .log p { 51 | max-width: none; 52 | } 53 | 54 | .milestone { 55 | color: darkorange; 56 | } 57 | 58 | .col-small { 59 | width: 5%; 60 | } 61 | 62 | .col-medium { 63 | width: 20%; 64 | } 65 | 66 | .col-large { 67 | width: 30%; 68 | } 69 | 70 | .action { 71 | padding: 2px; 72 | height: 1.8rem; 73 | vertical-align: middle; 74 | } 75 | 76 | section { 77 | margin-top: 1em; 78 | } 79 | 80 | .notification-list { 81 | border-bottom: none; 82 | } 83 | 84 | .back-one { 85 | z-index: -1; 86 | } 87 | 88 | .scroll-button { 89 | z-index: 10; 90 | } 91 | 92 | footer { 93 | position: fixed !important; 94 | left: 0; 95 | bottom: 0; 96 | width: 100%; 97 | margin-bottom: 1em; 98 | } 99 | 100 | .monitor { 101 | border: 4px solid #666666; 102 | background-color: #eeeeee; 103 | } 104 | 105 | .lozenge { 106 | border-radius: 10px; 107 | background-color: aliceblue; 108 | text-align: center; 109 | padding: 2px; 110 | font-size: small; 111 | } -------------------------------------------------------------------------------- /webapp/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import {T} from "./Utils"; 3 | 4 | 5 | let links = ['settings']; 6 | 7 | class Header extends Component { 8 | constructor(props) { 9 | super(props) 10 | this.state = {}; 11 | } 12 | 13 | link(l) { 14 | if (this.props.sectionId) { 15 | // This is the secondary menu 16 | return '/' + this.props.section + '/' + this.props.sectionId + '/' + l; 17 | } else { 18 | return '/' + l; 19 | } 20 | } 21 | 22 | render() { 23 | return ( 24 | 54 | ); 55 | } 56 | } 57 | 58 | export default Header; -------------------------------------------------------------------------------- /datastore/sqlite/crypt.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "errors" 9 | "io" 10 | "log" 11 | ) 12 | 13 | func generateSecret() (string, error) { 14 | rb := make([]byte, keyLength) 15 | _, err := rand.Read(rb) 16 | if err != nil { 17 | return "", err 18 | } 19 | return base64.URLEncoding.EncodeToString(rb), nil 20 | } 21 | 22 | // encryptKey uses symmetric encryption to encrypt the data for storage 23 | func encryptKey(plainTextKey, keyText string) (string, error) { 24 | // The AES key needs to be 16 or 32 bytes i.e. AES-128 or AES-256 25 | aesKey := padRight(keyText, "x", 32) 26 | 27 | block, err := aes.NewCipher([]byte(aesKey)) 28 | if err != nil { 29 | log.Printf("Error creating the cipher block: %v", err) 30 | return "", err 31 | } 32 | 33 | // The IV needs to be unique, but not secure. Including it at the start of the plaintext 34 | ciphertext := make([]byte, aes.BlockSize+len(plainTextKey)) 35 | iv := ciphertext[:aes.BlockSize] 36 | if _, err = io.ReadFull(rand.Reader, iv); err != nil { 37 | log.Printf("Error creating the IV for the cipher: %v", err) 38 | return "", err 39 | } 40 | 41 | // Use CFB mode for the encryption 42 | cfb := cipher.NewCFBEncrypter(block, iv) 43 | cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(plainTextKey)) 44 | 45 | return string(ciphertext), nil 46 | } 47 | 48 | // decryptKey handles the decryption of a sealed signing key 49 | func decryptKey(sealedKey []byte, keyText string) (string, error) { 50 | aesKey := padRight(keyText, "x", 32) 51 | 52 | block, err := aes.NewCipher([]byte(aesKey)) 53 | if err != nil { 54 | log.Printf("Error creating the cipher block: %v", err) 55 | return "", err 56 | } 57 | 58 | if len(sealedKey) < aes.BlockSize { 59 | return "", errors.New("cipher text too short") 60 | } 61 | 62 | iv := sealedKey[:aes.BlockSize] 63 | sealedKey = sealedKey[aes.BlockSize:] 64 | 65 | // Use CFB mode for the decryption 66 | cfb := cipher.NewCFBDecrypter(block, iv) 67 | cfb.XORKeyStream(sealedKey, sealedKey) 68 | 69 | return string(sealedKey), nil 70 | } 71 | 72 | // padRight truncates a string to a specific length, padding with a named 73 | // character for shorter strings. 74 | func padRight(str, pad string, length int) string { 75 | for { 76 | str += pad 77 | if len(str) > length { 78 | return str[0:length] 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /webapp/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /webapp/src/components/KeysList.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {formatError, T} from "./Utils"; 3 | import {Button, MainTable} from "@canonical/react-components"; 4 | import KeysAdd from "./KeysAdd"; 5 | import api from "./api"; 6 | 7 | class KeysList extends Component { 8 | constructor(props) { 9 | super(props) 10 | this.state = { 11 | showAdd: false, 12 | key: {}, 13 | } 14 | } 15 | 16 | handleCancelClick = (e) => { 17 | e.preventDefault() 18 | this.setState({showAdd: false}) 19 | } 20 | 21 | handleAddClick = (e) => { 22 | e.preventDefault() 23 | this.setState({showAdd: true}) 24 | } 25 | 26 | handleOnChange = (field, value) => { 27 | let key = this.state.key 28 | key[field] = value 29 | this.setState({key: key}) 30 | } 31 | 32 | handleCreate = (e) => { 33 | e.preventDefault() 34 | api.keysCreate(this.state.key).then(response => { 35 | this.props.onCreate() 36 | this.setState({error:'', showAdd: false, repo:''}) 37 | }) 38 | .catch(e => { 39 | console.log(formatError(e.response.data)) 40 | this.setState({error: formatError(e.response.data), message: ''}); 41 | }) 42 | } 43 | 44 | render() { 45 | let data = this.props.records.map(r => { 46 | return { 47 | columns:[ 48 | {content: r.name, role: 'rowheader'}, 49 | {content: r.created}, 50 | {content: ''} 51 | ], 52 | } 53 | }) 54 | 55 | return ( 56 |
57 |
58 |

{T('key-list')}

59 | 62 |
63 | 64 | {this.state.showAdd ? 65 | 68 | : 69 | '' 70 | } 71 | 72 | 82 |
83 | ); 84 | } 85 | } 86 | 87 | export default KeysList; -------------------------------------------------------------------------------- /service/rsa.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "golang.org/x/crypto/ssh" 9 | "io/ioutil" 10 | "log" 11 | ) 12 | 13 | const ( 14 | savePrivateFileTo = "/etc/ssh/ssh_host_rsa_key" 15 | savePublicFileTo = "/etc/ssh/ssh_host_rsa_key.pub" 16 | bitSize = 2048 17 | ) 18 | 19 | // GenerateHostKey generates the host key 20 | func GenerateHostKey() { 21 | privateKey, err := generatePrivateKey(bitSize) 22 | if err != nil { 23 | log.Fatal(err.Error()) 24 | } 25 | 26 | publicKeyBytes, err := generatePublicKey(&privateKey.PublicKey) 27 | if err != nil { 28 | log.Fatal(err.Error()) 29 | } 30 | 31 | privateKeyBytes := encodePrivateKeyToPEM(privateKey) 32 | 33 | err = writeKeyToFile(privateKeyBytes, savePrivateFileTo) 34 | if err != nil { 35 | log.Fatal(err.Error()) 36 | } 37 | 38 | err = writeKeyToFile(publicKeyBytes, savePublicFileTo) 39 | if err != nil { 40 | log.Fatal(err.Error()) 41 | } 42 | } 43 | 44 | // generatePrivateKey creates a RSA Private Key of specified byte size 45 | func generatePrivateKey(bitSize int) (*rsa.PrivateKey, error) { 46 | // Private Key generation 47 | privateKey, err := rsa.GenerateKey(rand.Reader, bitSize) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | // Validate Private Key 53 | err = privateKey.Validate() 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | log.Println("Private Key generated") 59 | return privateKey, nil 60 | } 61 | 62 | // generatePublicKey take a rsa.PublicKey and return bytes suitable for writing to .pub file 63 | // returns in the format "ssh-rsa ..." 64 | func generatePublicKey(privatekey *rsa.PublicKey) ([]byte, error) { 65 | publicRsaKey, err := ssh.NewPublicKey(privatekey) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | pubKeyBytes := ssh.MarshalAuthorizedKey(publicRsaKey) 71 | 72 | log.Println("Public key generated") 73 | return pubKeyBytes, nil 74 | } 75 | 76 | // encodePrivateKeyToPEM encodes Private Key from RSA to PEM format 77 | func encodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte { 78 | // Get ASN.1 DER format 79 | privDER := x509.MarshalPKCS1PrivateKey(privateKey) 80 | 81 | // pem.Block 82 | privBlock := pem.Block{ 83 | Type: "RSA PRIVATE KEY", 84 | Headers: nil, 85 | Bytes: privDER, 86 | } 87 | 88 | // Private key in PEM format 89 | privatePEM := pem.EncodeToMemory(&privBlock) 90 | 91 | return privatePEM 92 | } 93 | 94 | // writePemToFile writes keys to a file 95 | func writeKeyToFile(keyBytes []byte, saveFileTo string) error { 96 | err := ioutil.WriteFile(saveFileTo, keyBytes, 0600) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | log.Printf("Key saved to: %s", saveFileTo) 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /service/lxd/lxd.go: -------------------------------------------------------------------------------- 1 | package lxd 2 | 3 | import ( 4 | "fmt" 5 | lxd "github.com/lxc/lxd/client" 6 | "github.com/ogra1/fabrica/datastore" 7 | "github.com/ogra1/fabrica/domain" 8 | "github.com/ogra1/fabrica/service/system" 9 | "log" 10 | "os" 11 | "path" 12 | "time" 13 | ) 14 | 15 | // Service is the interface for the LXD service 16 | type Service interface { 17 | RunBuild(buildID, name, repo, branch, keyID, distro string) error 18 | GetImageAlias(name string) error 19 | CheckConnections() []domain.SettingAvailable 20 | StopAndDeleteContainer(name string) error 21 | } 22 | 23 | // LXD services 24 | type LXD struct { 25 | Datastore datastore.Datastore 26 | SystemSrv system.Srv 27 | } 28 | 29 | // NewLXD creates a new LXD client 30 | func NewLXD(ds datastore.Datastore, sysSrv system.Srv) *LXD { 31 | return &LXD{ 32 | Datastore: ds, 33 | SystemSrv: sysSrv, 34 | } 35 | } 36 | 37 | // connect opens a connection to the LXD service 38 | func (lx *LXD) connect() (lxd.InstanceServer, error) { 39 | // Get LXD socket path 40 | lxdSocket, err := lxdSocketPath() 41 | if err != nil { 42 | log.Println("Error with lxd socket:", err) 43 | return nil, err 44 | } 45 | 46 | // Connect to LXD over the Unix socket 47 | c, err := lxd.ConnectLXDUnix(lxdSocket, nil) 48 | if err != nil { 49 | log.Println("Error connecting to LXD:", err) 50 | return nil, err 51 | } 52 | return c, nil 53 | } 54 | 55 | // RunBuild runs a build by launching a container for the build request 56 | func (lx *LXD) RunBuild(buildID, name, repo, branch, keyID, distro string) error { 57 | // Create a new LXD connection 58 | c, err := lx.connect() 59 | if err != nil { 60 | lx.Datastore.BuildLogCreate(buildID, err.Error()) 61 | return err 62 | } 63 | 64 | // Run the build 65 | run := newRunner(buildID, lx.Datastore, lx.SystemSrv, c) 66 | return run.runBuild(name, repo, branch, keyID, distro) 67 | } 68 | 69 | // StopAndDeleteContainer stops and removes a container 70 | func (lx *LXD) StopAndDeleteContainer(name string) error { 71 | c, err := lx.connect() 72 | if err != nil { 73 | log.Println("Error connecting to LXD:", err) 74 | return err 75 | } 76 | 77 | return stopAndDeleteContainer(c, name) 78 | } 79 | 80 | func containerName(name string) string { 81 | return fmt.Sprintf("%s-%d", name, time.Now().Unix()) 82 | } 83 | 84 | // lxdSocketPath finds the socket path for LXD 85 | func lxdSocketPath() (string, error) { 86 | var ff = []string{ 87 | path.Join(os.Getenv("LXD_DIR"), "unix.socket"), 88 | "/var/snap/lxd/common/lxd/unix.socket", 89 | "/var/lib/lxd/unix.socket", 90 | } 91 | 92 | for _, f := range ff { 93 | if _, err := os.Stat(f); err == nil { 94 | return f, nil 95 | } 96 | } 97 | 98 | return "", fmt.Errorf("cannot find the LXD socket file") 99 | } 100 | -------------------------------------------------------------------------------- /webapp/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gorilla/mux" 6 | "github.com/ogra1/fabrica/config" 7 | "github.com/ogra1/fabrica/service/key" 8 | "github.com/ogra1/fabrica/service/lxd" 9 | "github.com/ogra1/fabrica/service/repo" 10 | "github.com/ogra1/fabrica/service/system" 11 | "net/http" 12 | ) 13 | 14 | // Web implements the web service 15 | type Web struct { 16 | Settings *config.Settings 17 | BuildSrv repo.BuildSrv 18 | LXDSrv lxd.Service 19 | SystemSrv system.Srv 20 | KeySrv key.Srv 21 | } 22 | 23 | // NewWebService starts a new web service 24 | func NewWebService(settings *config.Settings, bldSrv repo.BuildSrv, lxdSrv lxd.Service, systemSrv system.Srv, keySrv key.Srv) *Web { 25 | return &Web{ 26 | Settings: settings, 27 | BuildSrv: bldSrv, 28 | LXDSrv: lxdSrv, 29 | SystemSrv: systemSrv, 30 | KeySrv: keySrv, 31 | } 32 | } 33 | 34 | // Start the web service 35 | func (srv Web) Start() error { 36 | listenOn := fmt.Sprintf("%s:%s", "0.0.0.0", srv.Settings.Port) 37 | fmt.Printf("Starting service on port %s\n", listenOn) 38 | return http.ListenAndServe(listenOn, srv.Router()) 39 | } 40 | 41 | // Router returns the application router 42 | func (srv Web) Router() *mux.Router { 43 | // Start the web service router 44 | router := mux.NewRouter() 45 | 46 | router.Handle("/v1/repos", Middleware(http.HandlerFunc(srv.RepoList))).Methods("GET") 47 | router.Handle("/v1/repos", Middleware(http.HandlerFunc(srv.RepoCreate))).Methods("POST") 48 | router.Handle("/v1/repos/delete", Middleware(http.HandlerFunc(srv.RepoDelete))).Methods("POST") 49 | 50 | router.Handle("/v1/check/images", Middleware(http.HandlerFunc(srv.ImageAliases))).Methods("GET") 51 | router.Handle("/v1/check/connections", Middleware(http.HandlerFunc(srv.CheckConnections))).Methods("GET") 52 | 53 | router.Handle("/v1/build", Middleware(http.HandlerFunc(srv.Build))).Methods("POST") 54 | router.Handle("/v1/builds", Middleware(http.HandlerFunc(srv.BuildList))).Methods("GET") 55 | router.Handle("/v1/builds/{id}/download", Middleware(http.HandlerFunc(srv.BuildDownload))).Methods("GET") 56 | router.Handle("/v1/builds/{id}", Middleware(http.HandlerFunc(srv.BuildLog))).Methods("GET") 57 | router.Handle("/v1/builds/{id}", Middleware(http.HandlerFunc(srv.BuildDelete))).Methods("DELETE") 58 | 59 | router.Handle("/v1/system", Middleware(http.HandlerFunc(srv.SystemResources))).Methods("GET") 60 | router.Handle("/v1/system/environment", Middleware(http.HandlerFunc(srv.Environment))).Methods("GET") 61 | 62 | router.Handle("/v1/keys", Middleware(http.HandlerFunc(srv.KeyList))).Methods("GET") 63 | router.Handle("/v1/keys", Middleware(http.HandlerFunc(srv.KeyCreate))).Methods("POST") 64 | router.Handle("/v1/keys/{id}", Middleware(http.HandlerFunc(srv.KeyDelete))).Methods("DELETE") 65 | 66 | // Serve the static path 67 | fs := http.StripPrefix("/static/", http.FileServer(http.Dir(docRoot))) 68 | router.PathPrefix("/static/").Handler(fs) 69 | 70 | // Default path is the index page 71 | router.Handle("/", Middleware(http.HandlerFunc(srv.Index))).Methods("GET") 72 | router.Handle("/builds/{id}", Middleware(http.HandlerFunc(srv.Index))).Methods("GET") 73 | router.Handle("/builds/{id}/download", Middleware(http.HandlerFunc(srv.Index))).Methods("GET") 74 | router.Handle("/settings", Middleware(http.HandlerFunc(srv.Index))).Methods("GET") 75 | 76 | return router 77 | } 78 | -------------------------------------------------------------------------------- /datastore/sqlite/key.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/ogra1/fabrica/domain" 7 | "github.com/rs/xid" 8 | "log" 9 | "strings" 10 | ) 11 | 12 | const createKeysTableSQL = ` 13 | CREATE TABLE IF NOT EXISTS keys ( 14 | id varchar(200) primary key not null, 15 | name varchar(200) UNIQUE not null, 16 | data text not null, 17 | password varchar(200) default '', 18 | created timestamp default current_timestamp, 19 | modified timestamp default current_timestamp 20 | ) 21 | ` 22 | const addKeysSQL = ` 23 | INSERT INTO keys (id, name, data, password) VALUES ($1, $2, $3, $4) 24 | ` 25 | const getKeysSQL = ` 26 | SELECT id, name, data, password 27 | FROM keys 28 | WHERE id=$1 29 | ` 30 | const listKeysSQL = ` 31 | SELECT id, name, created 32 | FROM keys 33 | ORDER BY name 34 | ` 35 | const deleteKeysSQL = ` 36 | DELETE FROM keys WHERE id=$1 37 | ` 38 | 39 | // KeysCreate stores a new ssh key 40 | func (db *DB) KeysCreate(name, data, password string) (string, error) { 41 | // Get the secret key 42 | secret, err := db.secretKey() 43 | if err != nil { 44 | return "", err 45 | } 46 | 47 | // Encrypt the secret key 48 | dataEnc, err := encryptKey(data, secret) 49 | if err != nil { 50 | return "", err 51 | } 52 | passwordEnc, err := encryptKey(password, secret) 53 | if err != nil { 54 | return "", err 55 | } 56 | 57 | // Save the encrypted record 58 | id := xid.New() 59 | _, err = db.Exec(addKeysSQL, id.String(), name, dataEnc, passwordEnc) 60 | return id.String(), err 61 | } 62 | 63 | // KeysGet fetches an ssh key by its ID 64 | func (db *DB) KeysGet(id string) (domain.Key, error) { 65 | r := domain.Key{} 66 | err := db.QueryRow(getKeysSQL, id).Scan(&r.ID, &r.Name, &r.Data, &r.Password) 67 | switch { 68 | case err == sql.ErrNoRows: 69 | return r, err 70 | case err != nil: 71 | log.Printf("Error retrieving database repo: %v\n", err) 72 | return r, err 73 | } 74 | 75 | // Get the secret key 76 | secret, err := db.secretKey() 77 | if err != nil { 78 | return r, err 79 | } 80 | 81 | // Decrypt the data 82 | r.Data, err = decryptKey([]byte(r.Data), secret) 83 | if err != nil { 84 | return r, err 85 | } 86 | r.Password, err = decryptKey([]byte(r.Password), secret) 87 | if err != nil { 88 | return r, err 89 | } 90 | 91 | r.Data = strings.TrimSpace(r.Data) 92 | r.Password = strings.TrimSpace(r.Password) 93 | 94 | return r, nil 95 | } 96 | 97 | // KeysList get the list of ssh keys. Only unencrypted data is returned. 98 | func (db *DB) KeysList() ([]domain.Key, error) { 99 | records := []domain.Key{} 100 | rows, err := db.Query(listKeysSQL) 101 | if err != nil { 102 | return records, err 103 | } 104 | defer rows.Close() 105 | 106 | for rows.Next() { 107 | r := domain.Key{} 108 | err := rows.Scan(&r.ID, &r.Name, &r.Created) 109 | if err != nil { 110 | return records, err 111 | } 112 | records = append(records, r) 113 | } 114 | 115 | return records, nil 116 | } 117 | 118 | // KeysDelete removes a key from its name 119 | func (db *DB) KeysDelete(id string) error { 120 | // Check the key is not used 121 | repos, err := db.ReposForKey(id) 122 | if err != nil { 123 | return err 124 | } 125 | if len(repos) > 0 { 126 | return fmt.Errorf("the key is used by %d repositories", len(repos)) 127 | } 128 | 129 | _, err = db.Exec(deleteKeysSQL, id) 130 | return err 131 | } 132 | -------------------------------------------------------------------------------- /webapp/src/components/BuildLog.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import api from "./api"; 3 | import {formatError, T} from "./Utils"; 4 | import {Row, Notification, Button} from '@canonical/react-components' 5 | import DetailsCard from "./DetailsCard"; 6 | 7 | class BuildLog extends Component { 8 | constructor(props) { 9 | super(props) 10 | this.state = { 11 | build: {logs:[{message:'Getting ready'}, {message:'milestone: Loading...\n\n'}]}, 12 | //build: {}, 13 | error: '', 14 | scrollLog: false, 15 | } 16 | } 17 | 18 | componentDidMount() { 19 | this.getData() 20 | } 21 | 22 | poll = () => { 23 | // Polls every 0.5s 24 | setTimeout(this.getData.bind(this), 500); 25 | } 26 | 27 | getData() { 28 | api.buildGet(this.props.buildId).then(response => { 29 | this.setState({build: response.data.record, error:''}, this.scrollToBottom) 30 | }) 31 | .catch(e => { 32 | this.setState({error: formatError(e.response.data), message: ''}); 33 | }) 34 | .finally( ()=> { 35 | this.poll() 36 | }) 37 | } 38 | 39 | scrollToBottom() { 40 | if (this.state.scrollLog) { 41 | window.scrollTo(0, document.body.clientHeight) 42 | } 43 | } 44 | 45 | changeScroll() { 46 | if (!this.state.scrollLog) { 47 | window.scrollTo(0, 0) 48 | } 49 | } 50 | 51 | handleScrollClick = (e) => { 52 | this.setState({scrollLog: !this.state.scrollLog}, this.changeScroll) 53 | } 54 | 55 | renderLog() { 56 | if (!this.state.build.logs) { 57 | return ( 58 |
59 |

{T('getting-ready')}

60 |
61 | )} 62 | 63 | return ( 64 |
65 | {this.state.build.logs.map(l => { 66 | if (l.message.startsWith('milestone:')) { 67 | return

{l.message.replace('milestone:','')}

68 | } else { 69 | return

{l.message}

70 | } 71 | })} 72 |
73 | ) 74 | } 75 | 76 | render() { 77 | return ( 78 | 79 |

{T('build-log')}

80 | 81 | {this.state.error ? 82 | {this.state.error} 83 | : 84 | '' 85 | } 86 | 87 | 93 | 94 | {this.state.scrollLog ? 95 | '' 96 | : 97 | 98 | } 99 | 100 | {this.renderLog()} 101 | 102 | {this.state.scrollLog ? 103 | 104 | : 105 | '' 106 | } 107 | 108 |
109 | ); 110 | } 111 | } 112 | 113 | export default BuildLog; -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: fabrica 2 | base: core18 3 | version: '1.1.0' 4 | summary: your own snap build factory 5 | description: | 6 | Fabrica is a web service to be run on an lxd appliance. It spawns a 7 | web ui that allows you to point to cloneable git trees, initializes 8 | lxd containers and builds snap packages of the provided source trees. 9 | 10 | To use fabrica you can use the steps below 11 | 12 | snap install lxd 13 | sudo lxd init # hit enter for all questions 14 | snap install fabrica 15 | snap connect fabrica:lxd lxd:lxd 16 | snap connect fabrica:mount-observe 17 | snap connect fabrica:system-observe 18 | snap connect fabrica:ssh-keys 19 | 20 | Now fabrica will come up on port 8000. 21 | Point your web browser to http://localhost:8000 (or to the external 22 | IP instead of localhost) and add some git tree to it. 23 | 24 | Note that in its current state fabrica only builds snap packages for 25 | the used host architecture (i.e. to build armhf or arm64 snaps, you 26 | need to use a raspberry Pi4 and install lxd and fabrica on it) 27 | 28 | The branches are checked every 5 minutes for new commits. Builds start 29 | automatically when a new commit is detected. 30 | 31 | Since the service is currently only available via http and also has no 32 | authentication management, it is recommended to only use it in 33 | in-house setups. 34 | 35 | Issues and bugs should be filed at 36 | 37 | https://github.com/ogra1/fabrica/issues 38 | 39 | grade: stable 40 | confinement: strict 41 | 42 | architectures: 43 | - build-on: armhf 44 | - build-on: arm64 45 | - build-on: amd64 46 | - build-on: s390x 47 | - build-on: ppc64el 48 | 49 | layout: 50 | /etc/ssh: 51 | bind: $SNAP_DATA/ssh 52 | 53 | apps: 54 | init: 55 | command: bin/init.py 56 | daemon: simple 57 | plugs: 58 | - lxd 59 | - mount-observe 60 | - network-bind 61 | web: 62 | command: bin/web 63 | daemon: simple 64 | plugs: 65 | - lxd 66 | - mount-observe 67 | - network 68 | - network-bind 69 | - system-observe 70 | - ssh-keys 71 | watch: 72 | command: bin/watch 73 | daemon: simple 74 | plugs: 75 | - lxd 76 | - mount-observe 77 | - network 78 | - network-bind 79 | - system-observe 80 | - ssh-keys 81 | 82 | parts: 83 | react: 84 | plugin: nil 85 | source: . 86 | override-build: | 87 | # Install node and npm 88 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash 89 | export NVM_DIR="$HOME/.nvm" 90 | . "$NVM_DIR/nvm.sh" 91 | . "$NVM_DIR/bash_completion" 92 | 93 | nvm install lts/* 94 | nvm run node --version 95 | 96 | cd webapp 97 | npm install --unsafe-perm 98 | npm run build 99 | 100 | mkdir -p $SNAPCRAFT_PART_INSTALL/static/ 101 | cp -r build/static/css $SNAPCRAFT_PART_INSTALL/static/ 102 | cp -r build/static/js $SNAPCRAFT_PART_INSTALL/static/ 103 | cp -r build/static/images $SNAPCRAFT_PART_INSTALL/static/ 104 | cp build/* $SNAPCRAFT_PART_INSTALL/static/ || : 105 | build-packages: 106 | - curl 107 | - python-minimal 108 | - python-dev 109 | 110 | 111 | pylxd: 112 | plugin: python 113 | python-packages: 114 | - cryptography == 3.3.2 115 | - pylxd 116 | build-packages: 117 | - libffi-dev 118 | - libssl-dev 119 | scripts: 120 | source: . 121 | plugin: nil 122 | override-build: | 123 | snapcraftctl build 124 | mkdir -p $SNAPCRAFT_PART_INSTALL/bin 125 | cp bin/init.py $SNAPCRAFT_PART_INSTALL/bin/ 126 | 127 | cp -r ssh/ $SNAPCRAFT_PART_INSTALL 128 | chmod 644 $SNAPCRAFT_PART_INSTALL/ssh/* 129 | 130 | application: 131 | plugin: go 132 | source: . 133 | source-type: git 134 | build-packages: 135 | - gcc 136 | stage-packages: 137 | - git 138 | - libcurl4-openssl-dev 139 | 140 | bin: 141 | source: snap/local 142 | plugin: dump 143 | organize: 144 | "*": /bin/ 145 | 146 | -------------------------------------------------------------------------------- /datastore/sqlite/repo.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/ogra1/fabrica/domain" 6 | "github.com/rs/xid" 7 | "log" 8 | ) 9 | 10 | const createRepoTableSQL string = ` 11 | CREATE TABLE IF NOT EXISTS repo ( 12 | id varchar(200) primary key not null, 13 | name varchar(200) not null, 14 | location varchar(200) UNIQUE not null, 15 | hash varchar(200) default '', 16 | created timestamp default current_timestamp, 17 | modified timestamp default current_timestamp, 18 | branch varchar(200) default 'master', 19 | key_id varchar(200) default '' 20 | ) 21 | ` 22 | const alterRepoTableSQL string = ` 23 | ALTER TABLE repo ADD COLUMN branch varchar(200) default 'master' 24 | ` 25 | const alterRepoTableKeySQL string = ` 26 | ALTER TABLE repo ADD COLUMN key_id varchar(200) default '' 27 | ` 28 | const addRepoSQL = ` 29 | INSERT INTO repo (id, name, location, branch, key_id) VALUES ($1, $2, $3, $4, $5) 30 | ` 31 | const listRepoSQL = ` 32 | SELECT id, name, location, hash, created, modified, branch, key_id 33 | FROM repo 34 | ORDER BY name, location 35 | ` 36 | const listRepoWatchSQL = ` 37 | SELECT id, name, location, hash, created, modified, branch, key_id 38 | FROM repo 39 | ORDER BY modified 40 | ` 41 | const updateRepoHashSQL = ` 42 | UPDATE repo SET hash=$1, modified=current_timestamp WHERE id=$2 43 | ` 44 | const getRepoSQL = ` 45 | SELECT id, name, location, hash, created, modified, branch, key_id 46 | FROM repo 47 | WHERE id=$1 48 | ` 49 | const deleteRepoSQL = ` 50 | DELETE FROM repo WHERE id=$1 51 | ` 52 | const listReposForKeySQL = ` 53 | SELECT id, name, location, hash, created, modified, branch, key_id 54 | FROM repo 55 | WHERE key_id=$1 56 | ` 57 | 58 | // RepoCreate creates a new repository to watch 59 | func (db *DB) RepoCreate(name, repo, branch, keyID string) (string, error) { 60 | id := xid.New() 61 | _, err := db.Exec(addRepoSQL, id.String(), name, repo, branch, keyID) 62 | return id.String(), err 63 | } 64 | 65 | // RepoList get the list of repos 66 | func (db *DB) RepoList(watch bool) ([]domain.Repo, error) { 67 | // Order the list depending on use 68 | sql := listRepoSQL 69 | if watch { 70 | sql = listRepoWatchSQL 71 | } 72 | 73 | records := []domain.Repo{} 74 | rows, err := db.Query(sql) 75 | if err != nil { 76 | return records, err 77 | } 78 | defer rows.Close() 79 | 80 | for rows.Next() { 81 | r := domain.Repo{} 82 | err := rows.Scan(&r.ID, &r.Name, &r.Repo, &r.LastCommit, &r.Created, &r.Modified, &r.Branch, &r.KeyID) 83 | if err != nil { 84 | return records, err 85 | } 86 | records = append(records, r) 87 | } 88 | 89 | return records, nil 90 | } 91 | 92 | // RepoUpdateHash updates a repo's last commit hash 93 | func (db *DB) RepoUpdateHash(id, hash string) error { 94 | _, err := db.Exec(updateRepoHashSQL, hash, id) 95 | return err 96 | } 97 | 98 | // RepoGet fetches a repo from its ID 99 | func (db *DB) RepoGet(id string) (domain.Repo, error) { 100 | r := domain.Repo{} 101 | err := db.QueryRow(getRepoSQL, id).Scan(&r.ID, &r.Name, &r.Repo, &r.LastCommit, &r.Created, &r.Modified, &r.Branch, &r.KeyID) 102 | switch { 103 | case err == sql.ErrNoRows: 104 | return r, err 105 | case err != nil: 106 | log.Printf("Error retrieving database repo: %v\n", err) 107 | return r, err 108 | } 109 | return r, nil 110 | } 111 | 112 | // RepoDelete removes a repo from its ID 113 | func (db *DB) RepoDelete(id string) error { 114 | _, err := db.Exec(deleteRepoSQL, id) 115 | return err 116 | } 117 | 118 | // ReposForKey get the list repos using a key 119 | func (db *DB) ReposForKey(keyID string) ([]domain.Repo, error) { 120 | records := []domain.Repo{} 121 | rows, err := db.Query(listReposForKeySQL, keyID) 122 | if err != nil { 123 | return records, err 124 | } 125 | defer rows.Close() 126 | 127 | for rows.Next() { 128 | r := domain.Repo{} 129 | err := rows.Scan(&r.ID, &r.Name, &r.Repo, &r.LastCommit, &r.Created, &r.Modified, &r.Branch, &r.KeyID) 130 | if err != nil { 131 | return records, err 132 | } 133 | records = append(records, r) 134 | } 135 | 136 | return records, nil 137 | } 138 | -------------------------------------------------------------------------------- /webapp/src/components/BuildList.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {T} from "./Utils"; 3 | import {MainTable, Row, Modal} from "@canonical/react-components"; 4 | import BuildStatus from "./BuildStatus"; 5 | import BuildActions from "./BuildActions"; 6 | import moment from "moment"; 7 | 8 | class BuildList extends Component { 9 | constructor(props) { 10 | super(props) 11 | this.state = { 12 | confirmDelete: false, 13 | delete: {}, 14 | } 15 | } 16 | 17 | handleCancelDelete = (e) => { 18 | e.preventDefault() 19 | this.setState({confirmDelete: false, delete: {}}) 20 | } 21 | 22 | handleConfirmDelete = (e) => { 23 | e.preventDefault() 24 | let id = e.target.getAttribute('data-key') 25 | let buildIds = this.props.records.filter(rec => { 26 | return rec.id === id 27 | }) 28 | 29 | if (buildIds.length>0) { 30 | this.setState({confirmDelete: true, delete: buildIds[0]}) 31 | } 32 | } 33 | 34 | handleDoDelete = (e) => { 35 | e.preventDefault() 36 | 37 | this.props.onDelete(this.state.delete.id) 38 | this.setState({confirmDelete: false, delete: {}}) 39 | } 40 | 41 | renderConfirmDelete() { 42 | return ( 43 | 44 |

45 | {T('confirm-delete-message') + this.state.delete.name + ' (' + this.state.delete.created +')'} 46 |

47 |
48 |
49 | 52 | 55 |
56 |
57 | ) 58 | } 59 | 60 | render() { 61 | let data = this.props.records.map(r => { 62 | let dur = moment.duration(r.duration,'seconds').minutes() + ' minutes' 63 | if (r.duration < 120) { 64 | dur = moment.duration(r.duration,'seconds').seconds() + ' seconds' 65 | } 66 | 67 | return { 68 | columns:[ 69 | {content: r.name, role: 'rowheader'}, 70 | {content: r.repo}, 71 | {content: r.branch}, 72 | {content: r.created}, 73 | {content: }, 74 | {content: dur, className: "col-medium u-align--left"}, 75 | {content: , className: "col-medium u-align--left"} 76 | ], 77 | } 78 | }) 79 | 80 | return ( 81 |
82 | 83 | {this.state.confirmDelete ? this.renderConfirmDelete() : ''} 84 | 85 | 86 |

{T('build-requests')}

87 | 103 |
104 |
105 | ); 106 | } 107 | } 108 | 109 | export default BuildList; -------------------------------------------------------------------------------- /service/watch/watch.go: -------------------------------------------------------------------------------- 1 | package watch 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-git/go-git/v5" 6 | "github.com/go-git/go-git/v5/config" 7 | "github.com/go-git/go-git/v5/plumbing" 8 | "github.com/go-git/go-git/v5/storage/memory" 9 | "github.com/ogra1/fabrica/datastore" 10 | "github.com/ogra1/fabrica/domain" 11 | "github.com/ogra1/fabrica/service" 12 | "github.com/ogra1/fabrica/service/key" 13 | "github.com/ogra1/fabrica/service/repo" 14 | "log" 15 | "time" 16 | ) 17 | 18 | const ( 19 | tickInterval = 300 20 | ) 21 | 22 | // Srv interface for watching repos 23 | type Srv interface { 24 | Watch() 25 | } 26 | 27 | // Service implements a build service 28 | type Service struct { 29 | BuildSrv repo.BuildSrv 30 | Datastore datastore.Datastore 31 | KeySrv key.Srv 32 | } 33 | 34 | // NewWatchService creates a new watch service 35 | func NewWatchService(ds datastore.Datastore, buildSrv repo.BuildSrv, keySrv key.Srv) *Service { 36 | return &Service{ 37 | Datastore: ds, 38 | BuildSrv: buildSrv, 39 | KeySrv: keySrv, 40 | } 41 | } 42 | 43 | // Watch service to watch repo updates 44 | // The service runs on an interval and picks up the list of repos from the database 45 | // (fetching the most stale first). It goes through each repo, checking the latest 46 | // commit hash with the one stored in the database. As soon as it identifies one 47 | // repo that needs to be updated, it clones the repo and parse the snapcraft.yaml 48 | // to get the base. Then the lxd build process is started for that repo and the 49 | // commit hash is updated in the database. The watch service then has complete its 50 | // cycle and will wait for the next interval. So only one repo is processed in 51 | // each cycle. 52 | func (srv *Service) Watch() { 53 | // On an interval... 54 | ticker := time.NewTicker(time.Second * tickInterval) 55 | for range ticker.C { 56 | // Get the repo list 57 | records, err := srv.BuildSrv.RepoList(true) 58 | if err != nil { 59 | log.Println("Error fetching repositories:", err) 60 | break 61 | } 62 | 63 | for _, r := range records { 64 | log.Println("Check repo:", r.Repo) 65 | // check for an update 66 | hash, update, err := srv.checkForUpdates(r) 67 | if err != nil { 68 | log.Println("Error checking repository:", err) 69 | // check the next repo 70 | continue 71 | } 72 | if !update { 73 | // no update so check the next repo 74 | continue 75 | } 76 | 77 | // update the last commit hash (to avoid repeating builds) 78 | srv.Datastore.RepoUpdateHash(r.ID, hash) 79 | 80 | // trigger the build 81 | if _, err := srv.BuildSrv.Build(r.ID); err != nil { 82 | log.Println("Error building snap:", err) 83 | // check the next repo 84 | continue 85 | } 86 | 87 | // Don't process any more repos until the next cycle 88 | break 89 | } 90 | } 91 | ticker.Stop() 92 | } 93 | 94 | func (srv *Service) checkForUpdates(r domain.Repo) (string, bool, error) { 95 | // Get the last commit hash 96 | log.Println("git", "ls-remote", "--heads", r.Repo) 97 | rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ 98 | Name: "origin", 99 | URLs: []string{r.Repo}, 100 | }) 101 | 102 | // We can then use git ls-remote functions to retrieve wanted information 103 | // Use the ssh key for the repo, if needed 104 | options := srv.gitListOptions(r) 105 | refs, err := rem.List(options) 106 | if err != nil { 107 | return "", false, err 108 | } 109 | 110 | for _, ref := range refs { 111 | if checkBranch(r.Branch, ref) { 112 | return ref.Hash().String(), r.LastCommit != ref.Hash().String(), nil 113 | } 114 | } 115 | return "", false, fmt.Errorf("cannot find the repo HEAD") 116 | } 117 | 118 | func checkBranch(branch string, ref *plumbing.Reference) bool { 119 | name := fmt.Sprintf("refs/heads/%s", branch) 120 | return ref.Name().IsBranch() && ref.Name().String() == name 121 | } 122 | 123 | func (srv *Service) gitListOptions(r domain.Repo) *git.ListOptions { 124 | opt := &git.ListOptions{} 125 | // Check if an ssh key is needed 126 | if r.KeyID == "" { 127 | return opt 128 | } 129 | 130 | // Get the ssh key 131 | key, err := srv.Datastore.KeysGet(r.KeyID) 132 | if err != nil { 133 | log.Println("Error fetching ssh key:", err) 134 | return opt 135 | } 136 | 137 | // Set up the auth method 138 | pubKeys, err := service.GitAuth(key, r.Repo) 139 | if err != nil { 140 | return opt 141 | } 142 | opt.Auth = pubKeys 143 | return opt 144 | } 145 | -------------------------------------------------------------------------------- /datastore/sqlite/build.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/ogra1/fabrica/domain" 6 | "github.com/rs/xid" 7 | "log" 8 | ) 9 | 10 | const createBuildTableSQL string = ` 11 | CREATE TABLE IF NOT EXISTS build ( 12 | id varchar(200) primary key not null, 13 | name varchar(200) not null, 14 | repo varchar(200) not null, 15 | status varchar(20) default '', 16 | created timestamp default current_timestamp, 17 | download varchar(20) default '', 18 | duration int default 0, 19 | branch varchar(200) default 'master', 20 | container varchar(200) default '' 21 | ) 22 | ` 23 | const alterBuildTableSQL string = ` 24 | ALTER TABLE build ADD COLUMN branch varchar(200) default 'master' 25 | ` 26 | const alterBuildTableSQLcontainer string = ` 27 | ALTER TABLE build ADD COLUMN container varchar(200) default '' 28 | ` 29 | const addBuildSQL = ` 30 | INSERT INTO build (id, name, repo, branch) VALUES ($1, $2, $3, $4) 31 | ` 32 | const updateBuildSQL = ` 33 | UPDATE build SET status=$1,duration=$2 WHERE id=$3 34 | ` 35 | const updateBuildStatusSQL = ` 36 | UPDATE build SET status=$1 WHERE id=$2 37 | ` 38 | const updateBuildDownloadSQL = ` 39 | UPDATE build SET download=$1 WHERE id=$2 40 | ` 41 | const updateBuildContainerSQL = ` 42 | UPDATE build SET container=$1 WHERE id=$2 43 | ` 44 | const listBuildSQL = ` 45 | SELECT id, name, repo, status, created, download, duration, branch, container 46 | FROM build 47 | ORDER BY created DESC 48 | ` 49 | const getBuildSQL = ` 50 | SELECT id, name, repo, status, created, download, branch, container 51 | FROM build 52 | WHERE id=$1 53 | ` 54 | const deleteBuildSQL = ` 55 | DELETE FROM build WHERE id=$1 56 | ` 57 | const listBuildForRepoSQL = ` 58 | SELECT id, name, repo, status, created, download, branch, container 59 | FROM build 60 | WHERE repo=$1 and branch=$2 61 | ` 62 | 63 | // BuildCreate stores a new build request 64 | func (db *DB) BuildCreate(name, repo, branch string) (string, error) { 65 | id := xid.New() 66 | _, err := db.Exec(addBuildSQL, id.String(), name, repo, branch) 67 | return id.String(), err 68 | } 69 | 70 | // BuildUpdate updates a build request 71 | func (db *DB) BuildUpdate(id, status string, duration int) error { 72 | if duration == 0 { 73 | _, err := db.Exec(updateBuildStatusSQL, status, id) 74 | return err 75 | } 76 | 77 | _, err := db.Exec(updateBuildSQL, status, duration, id) 78 | return err 79 | } 80 | 81 | // BuildUpdateDownload updates a build request's download file path 82 | func (db *DB) BuildUpdateDownload(id, download string) error { 83 | _, err := db.Exec(updateBuildDownloadSQL, download, id) 84 | return err 85 | } 86 | 87 | // BuildUpdateContainer updates a build request's container name 88 | func (db *DB) BuildUpdateContainer(id, container string) error { 89 | _, err := db.Exec(updateBuildContainerSQL, container, id) 90 | return err 91 | } 92 | 93 | // BuildList get the list of builds 94 | func (db *DB) BuildList() ([]domain.Build, error) { 95 | logs := []domain.Build{} 96 | rows, err := db.Query(listBuildSQL) 97 | if err != nil { 98 | return logs, err 99 | } 100 | defer rows.Close() 101 | 102 | for rows.Next() { 103 | r := domain.Build{} 104 | err := rows.Scan(&r.ID, &r.Name, &r.Repo, &r.Status, &r.Created, &r.Download, &r.Duration, &r.Branch, &r.Container) 105 | if err != nil { 106 | return logs, err 107 | } 108 | logs = append(logs, r) 109 | } 110 | 111 | return logs, nil 112 | } 113 | 114 | // BuildGet fetches a build with its logs 115 | func (db *DB) BuildGet(id string) (domain.Build, error) { 116 | r := domain.Build{} 117 | err := db.QueryRow(getBuildSQL, id).Scan(&r.ID, &r.Name, &r.Repo, &r.Status, &r.Created, &r.Download, &r.Branch, &r.Container) 118 | switch { 119 | case err == sql.ErrNoRows: 120 | return r, err 121 | case err != nil: 122 | log.Printf("Error retrieving database build: %v\n", err) 123 | return r, err 124 | } 125 | 126 | logs, err := db.BuildLogList(id) 127 | if err != nil { 128 | return r, err 129 | } 130 | r.Logs = logs 131 | return r, nil 132 | } 133 | 134 | // BuildDelete delete a build request and its logs 135 | func (db *DB) BuildDelete(id string) error { 136 | // Delete the logs for this build 137 | _ = db.BuildLogDelete(id) 138 | 139 | _, err := db.Exec(deleteBuildSQL, id) 140 | return err 141 | } 142 | 143 | // BuildListForRepo get the list of builds for a repo 144 | func (db *DB) BuildListForRepo(name, branch string) ([]domain.Build, error) { 145 | logs := []domain.Build{} 146 | rows, err := db.Query(listBuildForRepoSQL, name, branch) 147 | if err != nil { 148 | return logs, err 149 | } 150 | defer rows.Close() 151 | 152 | for rows.Next() { 153 | r := domain.Build{} 154 | err := rows.Scan(&r.ID, &r.Name, &r.Repo, &r.Status, &r.Created, &r.Download, &r.Branch, &r.Container) 155 | if err != nil { 156 | return logs, err 157 | } 158 | logs = append(logs, r) 159 | } 160 | 161 | return logs, nil 162 | } 163 | -------------------------------------------------------------------------------- /webapp/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /webapp/src/components/RepoList.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import api from "./api"; 3 | import {formatError, T} from "./Utils"; 4 | import RepoAdd from "./RepoAdd"; 5 | import {MainTable, Row, Button} from "@canonical/react-components"; 6 | import RepoActions from "./RepoActions"; 7 | import RepoDelete from "./RepoDelete"; 8 | 9 | class RepoList extends Component { 10 | constructor(props) { 11 | super(props) 12 | this.state = { 13 | showAdd: false, 14 | repo: '', 15 | branch: 'master', 16 | keyId: '', 17 | showDelete: false, 18 | delete: {deleteBuilds:false}, 19 | } 20 | } 21 | 22 | keyName(keyId) { 23 | if (!keyId) { 24 | return '' 25 | } 26 | let names = this.props.keys.filter(k => { 27 | return k.id === keyId 28 | }) 29 | if (names.length > 0) { 30 | return names[0].name 31 | } 32 | return '' 33 | } 34 | 35 | handleAddClick = (e) => { 36 | e.preventDefault() 37 | this.setState({showAdd: true}) 38 | } 39 | 40 | handleCancelClick = (e) => { 41 | e.preventDefault() 42 | this.setState({showAdd: false, showDelete: false, delete: {deleteBuilds:false}}) 43 | } 44 | 45 | handleDeleteBuildsClick = (e) => { 46 | e.preventDefault() 47 | let del = this.state.delete 48 | del.deleteBuilds = !del.deleteBuilds 49 | this.setState({delete: del}) 50 | } 51 | 52 | handleDeleteClick = (e) => { 53 | e.preventDefault() 54 | let id = e.target.getAttribute('data-key') 55 | 56 | let rr = this.props.records.filter(r => { 57 | return r.id === id 58 | }) 59 | 60 | let del = this.state.delete 61 | del.id = id 62 | del.repo = rr[0].repo 63 | this.setState({showDelete: true, delete: del}) 64 | } 65 | 66 | handleDeleteDo = (e) => { 67 | e.preventDefault() 68 | 69 | this.props.onDelete(this.state.delete.id, this.state.delete.deleteBuilds) 70 | this.setState({showDelete: false, delete: {deleteBuilds:false}}) 71 | } 72 | 73 | handleRepoChange = (e) => { 74 | e.preventDefault() 75 | this.setState({repo: e.target.value}) 76 | } 77 | 78 | handleBranchChange = (e) => { 79 | e.preventDefault() 80 | this.setState({branch: e.target.value}) 81 | } 82 | 83 | handleKeyIdChange = (e) => { 84 | e.preventDefault() 85 | this.setState({keyId: e.target.value}) 86 | } 87 | 88 | handleRepoCreate = (e) => { 89 | e.preventDefault() 90 | api.repoCreate(this.state.repo, this.state.branch, this.state.keyId).then(response => { 91 | this.props.onCreate() 92 | this.setState({error:'', showAdd: false, repo:''}) 93 | }) 94 | .catch(e => { 95 | console.log(formatError(e.response.data)) 96 | this.setState({error: formatError(e.response.data), message: ''}); 97 | }) 98 | } 99 | 100 | render() { 101 | let data = this.props.records.map(r => { 102 | return { 103 | columns:[ 104 | {content: r.name, role: 'rowheader'}, 105 | {content: r.repo}, 106 | {content: r.branch}, 107 | {content: this.keyName(r.keyId)}, 108 | {content: r.hash}, 109 | {content: r.created}, 110 | {content: r.modified}, 111 | {content: } 112 | ], 113 | } 114 | }) 115 | 116 | return ( 117 |
118 | 119 |
120 |

{T('repo-list')}

121 | 124 |
125 | {this.state.showAdd ? 126 | 129 | : 130 | '' 131 | } 132 | {this.state.showDelete ? 133 | 134 | : '' 135 | } 136 | 156 |
157 |
158 | ); 159 | } 160 | } 161 | 162 | export default RepoList; -------------------------------------------------------------------------------- /webapp/src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import RepoList from "./RepoList"; 3 | import BuildList from "./BuildList"; 4 | import api from "./api"; 5 | import {T, formatError} from "./Utils"; 6 | import {Row, Notification} from '@canonical/react-components' 7 | import ImageList from "./ImageList"; 8 | import ConnectionList from "./ConnectionList"; 9 | 10 | class Home extends Component { 11 | constructor(props) { 12 | super(props) 13 | this.state = { 14 | ready: true, 15 | connectReady: true, 16 | images: [{name: 'fabrica-focal', available: true}, {name: 'fabrica-bionic', available: true}, {name: 'fabrica-xenial', available: false}], 17 | connections: [{name: 'lxd', available: true}, {name: 'system-observe', available: false}], 18 | repos: [ 19 | {id:'aaa', name:'test', repo:'github.com/TestCompany/test', keyId:'a123', branch:'master', hash:'abcdef', created:'2020-05-14T19:01:34Z', modified:'2020-05-14T19:01:34Z'} 20 | ], 21 | builds: [ 22 | {id:'bbb', name:'test', repo:'github.com/TestCompany/test', branch:'master', status:'in-progress', duration: 222, created:'2020-05-14T19:30:34Z'} 23 | ], 24 | keys: [ 25 | {id:'a123', name:'example', username:'example'} 26 | ] 27 | } 28 | } 29 | 30 | componentDidMount() { 31 | this.getDataRepos() 32 | this.getDataBuilds() 33 | this.getDataConnections() 34 | this.getDataImages() 35 | this.getDataKeys() 36 | } 37 | 38 | poll = () => { 39 | // Polls every 30s 40 | setTimeout(this.getDataBuilds.bind(this), 30000); 41 | } 42 | 43 | pollImages = () => { 44 | // Polls every 2s 45 | if (!this.state.ready) { 46 | setTimeout(this.getDataImages.bind(this), 2000); 47 | } 48 | } 49 | 50 | pollConnections = () => { 51 | // Polls every 2s 52 | if (!this.state.connectReady) { 53 | setTimeout(this.getDataConnections.bind(this), 2000); 54 | } 55 | } 56 | 57 | getDataImages() { 58 | api.imageList().then(response => { 59 | let ready = true 60 | response.data.records.map(r => { 61 | ready = ready && r.available 62 | return ready 63 | }) 64 | 65 | this.setState({images: response.data.records, ready: ready}) 66 | }) 67 | .catch(e => { 68 | console.log(formatError(e.response.data)) 69 | this.setState({error: formatError(e.response.data), message: ''}); 70 | }) 71 | .finally( ()=> { 72 | this.pollImages() 73 | }) 74 | } 75 | 76 | getDataConnections() { 77 | api.connectionList().then(response => { 78 | let ready = true 79 | response.data.records.map(r => { 80 | ready = ready && r.available 81 | return ready 82 | }) 83 | 84 | this.setState({connections: response.data.records, connectReady: ready}) 85 | }) 86 | .catch(e => { 87 | console.log(formatError(e.response.data)) 88 | this.setState({error: formatError(e.response.data), message: ''}); 89 | }) 90 | .finally( ()=> { 91 | this.pollConnections() 92 | }) 93 | } 94 | 95 | getDataRepos() { 96 | api.repoList().then(response => { 97 | this.setState({repos: response.data.records}) 98 | }) 99 | .catch(e => { 100 | console.log(formatError(e.response.data)) 101 | this.setState({error: formatError(e.response.data), message: ''}); 102 | }) 103 | } 104 | 105 | getDataKeys() { 106 | api.keysList().then(response => { 107 | this.setState({keys: response.data.records}) 108 | }) 109 | .catch(e => { 110 | console.log(formatError(e.response.data)) 111 | this.setState({error: formatError(e.response.data), message: ''}); 112 | }) 113 | } 114 | 115 | getDataBuilds() { 116 | api.buildList().then(response => { 117 | this.setState({builds: response.data.records}) 118 | }) 119 | .catch(e => { 120 | console.log(formatError(e.response.data)) 121 | this.setState({error: formatError(e.response.data), message: ''}); 122 | }) 123 | .finally( ()=> { 124 | this.poll() 125 | }) 126 | } 127 | 128 | handleRepoDelete = (repoId, deleteBuilds) => { 129 | api.repoDelete(repoId, deleteBuilds).then(response => { 130 | this.getDataRepos() 131 | this.getDataBuilds() 132 | }) 133 | .catch(e => { 134 | console.log(formatError(e.response.data)) 135 | this.setState({error: formatError(e.response.data), message: ''}); 136 | }) 137 | } 138 | 139 | handleRepoCreateClick = () => { 140 | this.getDataRepos() 141 | } 142 | 143 | handleBuildClick = (e) => { 144 | e.preventDefault() 145 | let repoId = e.target.getAttribute('data-key') 146 | 147 | api.build(repoId).then(response => { 148 | this.getDataBuilds() 149 | }) 150 | .catch(e => { 151 | console.log(formatError(e.response.data)) 152 | this.setState({error: formatError(e.response.data), message: ''}); 153 | }) 154 | } 155 | 156 | handleBuildDelete = (buildId) => { 157 | api.buildDelete(buildId).then(response => { 158 | this.getDataBuilds() 159 | }) 160 | .catch(e => { 161 | console.log(formatError(e.response.data)) 162 | this.setState({error: formatError(e.response.data), message: ''}); 163 | }) 164 | } 165 | 166 | render() { 167 | return ( 168 |
169 | { 170 | this.state.error ? 171 | 172 | 173 | {this.state.error} 174 | 175 | 176 | : '' 177 | } 178 | { 179 | this.state.connectReady ? 180 | '' : 181 | 182 | } 183 | { 184 | this.state.ready ? 185 | '' : 186 | 187 | } 188 | 189 | 190 |
191 | ); 192 | } 193 | } 194 | 195 | export default Home; 196 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # Fabrica - API 2 | Fabrica provides an open API that can be used to perform operations from an external 3 | script or application. The API returns a JSON message with some common attributes: 4 | 5 | - `code`: error code (empty string when the operation is successful) 6 | - `message`: descriptive error message (empty string when the operation is successful) 7 | - `records` (optional): a list of objects e.g. a list of repositories 8 | - `record` (optional): a single object e.g. the details of a build 9 | 10 | ### Examples 11 | > **List the repositories** 12 | > ``` 13 | > curl http://localhost:8000/v1/repos 14 | > ``` 15 | > ``` 16 | > { 17 | > "code": "", 18 | > "message": "", 19 | > "records": [ 20 | > { 21 | > "id": "btqvgbp105lvsv3ubhd0", 22 | > "name": "fabrica", 23 | > "repo": "https://github.com/ogra1/fabrica", 24 | > "branch": "master", 25 | > "keyId": "", 26 | > "hash": "", 27 | > "created": "2020-10-01T15:39:27Z", 28 | > "modified": "2020-10-01T15:39:27Z" 29 | > }, 30 | > { 31 | > "id": "btqvh19105lvsv3ubhdg", 32 | > "name": "logsync", 33 | > "repo": "https://github.com/slimjim777/logsync", 34 | > "branch": "master", 35 | > "keyId": "", 36 | > "hash": "", 37 | > "created": "2020-10-01T15:40:53Z", 38 | > "modified": "2020-10-01T15:40:53Z" 39 | > } 40 | > ] 41 | > } 42 | > ``` 43 | 44 | > **Create a repository** 45 | > ``` 46 | > curl -X POST -d '{"repo":"https://github.com/ogra1/fabrica", "branch":"master", "keyId":""}' http://localhost:8000/v1/repos 47 | > ``` 48 | > ``` 49 | > { 50 | > "code": "", 51 | > "message": "btqvgbp105lvsv3ubhd0" 52 | > } 53 | > ``` 54 | 55 | ### List repositories 56 | `GET /v1/repos` 57 | 58 | Retrieve a list of the watched repositories. 59 | 60 | **Request** 61 | 62 | ``` 63 | curl http://localhost:8000/v1/repos 64 | ``` 65 | 66 | **Response** 67 | 68 | | Attribute | Type | Description | 69 | | --------- | ------ | --------------------- | 70 | | code | string | Error code | 71 | | message | string | Error description | 72 | | records | array | List of repositories | 73 | 74 | 75 | 76 | ### Create a repository 77 | `POST /v1/repos` 78 | 79 | Create a new watched repository. 80 | 81 | **Request** 82 | 83 | | Attribute | Type | Description | 84 | | --------- | ------ | --------------------- | 85 | | repo | string | URL of the repository | 86 | | branch | string | Branch of the repository | 87 | | keyId | string | The ID of the ssh key | 88 | 89 | 90 | **Response** 91 | 92 | | Attribute | Type | Description | 93 | | --------- | ------ | --------------------- | 94 | | code | string | Error code | 95 | | message | string | ID of the repository | 96 | 97 | 98 | 99 | ### Delete a repository 100 | `POST /v1/repos/delete` 101 | 102 | Delete a repository and, optionally, delete its builds. 103 | 104 | **Request** 105 | 106 | | Attribute | Type | Description | 107 | | --------- | ------ | --------------------- | 108 | | id | string | ID of the repository | 109 | | deleteBuilds | bool | `true` if the builds are to be deleted | 110 | 111 | 112 | **Response** 113 | 114 | | Attribute | Type | Description | 115 | | --------- | ------ | --------------------- | 116 | | code | string | Error code | 117 | | message | string | Error description | 118 | 119 | 120 | ### List builds 121 | `GET /v1/builds` 122 | 123 | Lists the build records. 124 | 125 | **Request** 126 | ``` 127 | curl http://localhost:8000/v1/builds 128 | ``` 129 | 130 | **Response** 131 | 132 | | Attribute | Type | Description | 133 | | --------- | ------ | --------------------- | 134 | | code | string | Error code | 135 | | message | string | Error description | 136 | | records | array | List of builds | 137 | 138 | 139 | ### Submit a build 140 | `POST /v1/build` 141 | 142 | Launches a new build for a repository. 143 | 144 | **Request** 145 | 146 | | Attribute | Type | Description | 147 | | --------- | ------ | --------------------- | 148 | | repo | object | the repository that us to be built | 149 | 150 | The repository object is similar to the records that are returned from the List 151 | Repository command. 152 | 153 | E.g. 154 | ``` 155 | { 156 | "repo": "https://github.com/ogra1/fabrica", 157 | "branch": "master", 158 | "keyId": "" 159 | } 160 | ``` 161 | 162 | **Response** 163 | 164 | | Attribute | Type | Description | 165 | | --------- | ------ | --------------------- | 166 | | code | string | Error code | 167 | | message | string | ID of the build | 168 | 169 | 170 | 171 | 172 | ### Fetch an existing build 173 | `GET /v1/builds/{buildId}` 174 | 175 | Fetches the details of a build. 176 | 177 | **Request** 178 | ``` 179 | curl http://localhost:8000/v1/builds/{buildId} 180 | ``` 181 | Where `buildId` is the ID of the build (as seen in the List builds command). 182 | 183 | **Response** 184 | 185 | | Attribute | Type | Description | 186 | | --------- | ------ | --------------------- | 187 | | code | string | Error code | 188 | | message | string | Error description | 189 | | record | object | The build record | 190 | 191 | The build record includes details of the build including: 192 | 193 | - `status`: the status of the build e.g. `complete`. 194 | - `logs`: an array of log objects, one for each line of the build log. 195 | - `container`: the name of the LXD container. 196 | 197 | 198 | ### Delete a build 199 | `DELETE /v1/builds/{buildId}` 200 | 201 | Deletes a build and its generated assets. 202 | 203 | **Request** 204 | ``` 205 | curl -X DELETE http://localhost:8000/v1/builds/{buildId} 206 | ``` 207 | Where `buildId` is the ID of the build (as seen in the List builds command). 208 | 209 | **Response** 210 | 211 | | Attribute | Type | Description | 212 | | --------- | ------ | --------------------- | 213 | | code | string | Error code | 214 | | message | string | Error description | 215 | 216 | 217 | ### List ssh keys 218 | `GET /v1/keys` 219 | 220 | **Request** 221 | ``` 222 | curl http://localhost:8000/v1/keys 223 | ``` 224 | 225 | **Response** 226 | 227 | | Attribute | Type | Description | 228 | | --------- | ------ | --------------------- | 229 | | code | string | Error code | 230 | | message | string | Error description | 231 | | records | array | List of ssh keys | 232 | 233 | 234 | 235 | ### Register an ssh key 236 | `POST /v1/keys` 237 | 238 | Register an ssh key with the service. 239 | 240 | **Request** 241 | 242 | | Attribute | Type | Description | 243 | | --------- | ------ | --------------------- | 244 | | name | string | Descriptive name of the key | 245 | | data | string | Base64-encoded ssh private key | 246 | 247 | **Response** 248 | 249 | | Attribute | Type | Description | 250 | | --------- | ------ | --------------------- | 251 | | code | string | Error code | 252 | | message | string | ID of the ssh-key record | 253 | -------------------------------------------------------------------------------- /service/repo/build.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-git/go-git/v5" 6 | "github.com/go-git/go-git/v5/plumbing" 7 | "github.com/go-git/go-git/v5/plumbing/transport" 8 | "github.com/ogra1/fabrica/datastore" 9 | "github.com/ogra1/fabrica/domain" 10 | "github.com/ogra1/fabrica/service" 11 | "github.com/ogra1/fabrica/service/lxd" 12 | "gopkg.in/yaml.v2" 13 | "io/ioutil" 14 | "log" 15 | "os" 16 | "path" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | const ( 22 | statusInProgress = "in-progress" 23 | statusFailed = "failed" 24 | statusComplete = "complete" 25 | downloadFileMessage = "Archived snap package: " 26 | ) 27 | 28 | // BuildSrv interface for building images 29 | type BuildSrv interface { 30 | Build(repoID string) (string, error) 31 | List() ([]domain.Build, error) 32 | BuildGet(id string) (domain.Build, error) 33 | BuildDelete(id string) error 34 | 35 | RepoCreate(repo, branch, keyID string) (string, error) 36 | RepoList(watch bool) ([]domain.Repo, error) 37 | RepoDelete(id string, deleteBuilds bool) error 38 | } 39 | 40 | // BuildService implements a build service 41 | type BuildService struct { 42 | Datastore datastore.Datastore 43 | LXDSrv lxd.Service 44 | } 45 | 46 | // NewBuildService creates a new build service 47 | func NewBuildService(ds datastore.Datastore, lx lxd.Service) *BuildService { 48 | return &BuildService{ 49 | Datastore: ds, 50 | LXDSrv: lx, 51 | } 52 | } 53 | 54 | // Build starts a build with lxd 55 | func (bld *BuildService) Build(repoID string) (string, error) { 56 | // Get the repo from the ID 57 | repo, err := bld.Datastore.RepoGet(repoID) 58 | if err != nil { 59 | return "", fmt.Errorf("cannot find the repository: %v", err) 60 | } 61 | 62 | // Store the build request 63 | buildID, err := bld.Datastore.BuildCreate(repo.Name, repo.Repo, repo.Branch) 64 | if err != nil { 65 | return buildID, fmt.Errorf("error storing build request: %v", err) 66 | } 67 | 68 | // Start the build in a go routine 69 | go bld.requestBuild(repo, buildID) 70 | 71 | return buildID, nil 72 | } 73 | 74 | func (bld *BuildService) requestBuild(repo domain.Repo, buildID string) error { 75 | start := time.Now() 76 | // Update build status 77 | _ = bld.Datastore.BuildUpdate(buildID, statusInProgress, 0) 78 | 79 | // Clone the repo and get the last commit tag (we need the repo to parse the snapcraft.yaml file) 80 | repoPath, hash, err := bld.cloneRepo(repo) 81 | if err != nil { 82 | log.Println("Cloning repository:", err) 83 | duration := time.Now().Sub(start).Seconds() 84 | _ = bld.Datastore.BuildLogCreate(buildID, fmt.Sprintf("Error cloning repo: %v\n", err)) 85 | _ = bld.Datastore.BuildUpdate(buildID, statusFailed, int(duration)) 86 | return err 87 | } 88 | log.Printf("Cloned repo: %s (%s)\n", repoPath, hash) 89 | bld.Datastore.BuildLogCreate(buildID, fmt.Sprintf("Cloned repo: %s (%s)\n", repoPath, hash)) 90 | 91 | // Find the snapcraft.yaml file 92 | f, err := bld.findSnapcraftYAML(repoPath) 93 | if err != nil { 94 | log.Println("Find snapcraft.yaml:", err) 95 | duration := time.Now().Sub(start).Seconds() 96 | _ = bld.Datastore.BuildLogCreate(buildID, fmt.Sprintf("Find snapcraft.yaml: %v\n", err)) 97 | _ = bld.Datastore.BuildUpdate(buildID, statusFailed, int(duration)) 98 | return err 99 | } 100 | log.Printf("snapcraft.yaml: %s\n", f) 101 | bld.Datastore.BuildLogCreate(buildID, fmt.Sprintf("snapcraft.yaml: %s\n", f)) 102 | 103 | // Get the distro from looking at the `base` keyword in snapcraft.yaml 104 | distro, err := bld.getDistroFromYAML(f) 105 | if err != nil { 106 | log.Println("Get distro:", err) 107 | duration := time.Now().Sub(start).Seconds() 108 | _ = bld.Datastore.BuildLogCreate(buildID, fmt.Sprintf("Get distro: %v\n", err)) 109 | _ = bld.Datastore.BuildUpdate(buildID, statusFailed, int(duration)) 110 | return err 111 | } 112 | bld.Datastore.BuildLogCreate(buildID, fmt.Sprintf("Distro: %s\n", distro)) 113 | 114 | // Clean up the cloned repo 115 | _ = os.RemoveAll(repoPath) 116 | 117 | // Run the build in an LXD container 118 | if err := bld.LXDSrv.RunBuild(buildID, repo.Name, repo.Repo, repo.Branch, repo.KeyID, distro); err != nil { 119 | duration := time.Now().Sub(start).Seconds() 120 | _ = bld.Datastore.BuildUpdate(buildID, statusFailed, int(duration)) 121 | return err 122 | } 123 | 124 | // Update the repo's last commit 125 | _ = bld.Datastore.RepoUpdateHash(repo.ID, hash) 126 | 127 | // Mark the build as complete 128 | duration := time.Now().Sub(start).Seconds() 129 | _ = bld.Datastore.BuildUpdate(buildID, statusComplete, int(duration)) 130 | return nil 131 | } 132 | 133 | // cloneRepo the repo and return the path and tag 134 | func (bld *BuildService) cloneRepo(r domain.Repo) (string, string, error) { 135 | // Prepare to clone the repo 136 | p := service.GetPath(r.ID) 137 | log.Println("git", "clone", "--depth", "1", r.Repo, p) 138 | refBranch := plumbing.NewBranchReferenceName(r.Branch) 139 | 140 | cloneOptions := &git.CloneOptions{ 141 | URL: r.Repo, 142 | ReferenceName: refBranch, 143 | SingleBranch: true, 144 | Depth: 1, 145 | } 146 | 147 | // Set the auth options, if needed 148 | if r.KeyID != "" { 149 | auth, err := bld.gitAuth(r) 150 | if err != nil { 151 | log.Println("Error setting auth option:", err) 152 | return "", "", err 153 | } 154 | cloneOptions.Auth = auth 155 | } 156 | 157 | // Clone the repo 158 | gitRepo, err := git.PlainClone(p, false, cloneOptions) 159 | if err != nil { 160 | log.Println("Error cloning repo:", err) 161 | return "", "", err 162 | } 163 | 164 | // Get the last commit hash 165 | log.Println("git", "ls-remote", "--heads", p) 166 | ref, err := gitRepo.Head() 167 | if err != nil { 168 | return "", "", err 169 | } 170 | return p, ref.Hash().String(), nil 171 | } 172 | 173 | func (bld *BuildService) findSnapcraftYAML(p string) (string, error) { 174 | // Check the root directory for snapcraft.yaml 175 | f := path.Join(p, "snapcraft.yaml") 176 | log.Println("Checking path:", f) 177 | _, err := os.Stat(f) 178 | if err == nil { 179 | return f, nil 180 | } 181 | 182 | // Check the root directory for snapcraft.yaml 183 | f = path.Join(p, "snap", "snapcraft.yaml") 184 | log.Println("Checking path:", f) 185 | _, err = os.Stat(f) 186 | if err == nil { 187 | return f, nil 188 | } 189 | 190 | return "", fmt.Errorf("cannot file snapcraft.yaml in repository") 191 | } 192 | 193 | func (bld *BuildService) getDistroFromYAML(f string) (string, error) { 194 | data, err := ioutil.ReadFile(f) 195 | if err != nil { 196 | return "", err 197 | } 198 | 199 | keys := map[string]interface{}{} 200 | if err := yaml.Unmarshal(data, &keys); err != nil { 201 | return "", err 202 | } 203 | 204 | base, ok := keys["base"].(string) 205 | if !ok { 206 | // Default to xenial when there is no base defined 207 | return "xenial", nil 208 | } 209 | 210 | // Convert the base to a distro 211 | switch base { 212 | case "core18": 213 | return "bionic", nil 214 | case "core20": 215 | return "focal", nil 216 | default: 217 | return "xenial", nil 218 | } 219 | } 220 | 221 | func (bld *BuildService) gitAuth(r domain.Repo) (transport.AuthMethod, error) { 222 | // Get the ssh key 223 | key, err := bld.Datastore.KeysGet(r.KeyID) 224 | if err != nil { 225 | log.Println("Error fetching ssh key:", err) 226 | return nil, err 227 | } 228 | 229 | // Set the ssh auth for git 230 | return service.GitAuth(key, r.Repo) 231 | } 232 | 233 | // checkForDownloadFile parses the message to see if we have the download file path 234 | func (bld *BuildService) checkForDownloadFile(buildID, message string) { 235 | p := strings.TrimPrefix(message, downloadFileMessage) 236 | if err := bld.Datastore.BuildUpdateDownload(buildID, p); err != nil { 237 | log.Println("Error storing download path:", err) 238 | } 239 | } 240 | 241 | func nameFromRepo(repo string) string { 242 | base := path.Base(repo) 243 | return strings.TrimSuffix(base, ".git") 244 | } 245 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= 2 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= 3 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 4 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= 8 | github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= 9 | github.com/flosch/pongo2 v0.0.0-20200518135938-dfb43dbdc22a h1:8Dw1FO25BORXdlipopkXixOKPe3qjfqfxUOb327zP48= 10 | github.com/flosch/pongo2 v0.0.0-20200518135938-dfb43dbdc22a/go.mod h1:StS3bHLP8nf6A+gzLIW2rrGeSCZrS0DMNTrIEEPRHz0= 11 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 12 | github.com/frankban/quicktest v1.0.0/go.mod h1:R98jIehRai+d1/3Hv2//jOVCTJhW1VBavT6B6CuGq2k= 13 | github.com/frankban/quicktest v1.1.0/go.mod h1:R98jIehRai+d1/3Hv2//jOVCTJhW1VBavT6B6CuGq2k= 14 | github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20= 15 | github.com/frankban/quicktest v1.7.3/go.mod h1:V1d2J5pfxYH6EjBAgSK7YNXcXlTWxUHdE1sVDXkjnig= 16 | github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 17 | github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= 18 | github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= 19 | github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= 20 | github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM= 21 | github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 22 | github.com/go-git/go-git v4.7.0+incompatible h1:+W9rgGY4DOKKdX2x6HxSR7HNeTxqiKrOvKnuittYVdA= 23 | github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= 24 | github.com/go-git/go-git/v5 v5.0.0 h1:k5RWPm4iJwYtfWoxIJy4wJX9ON7ihPeZZYC1fLYDnpg= 25 | github.com/go-git/go-git/v5 v5.0.0/go.mod h1:oYD8y9kWsGINPFJoLdaScGCN6dlKg23blmClfZwtUVA= 26 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 27 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 28 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 29 | github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 30 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 31 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 | github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= 33 | github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 34 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 35 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 36 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 37 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 38 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 39 | github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5 h1:rhqTjzJlm7EbkELJDKMTU7udov+Se0xZkWmugr6zGok= 40 | github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q= 41 | github.com/juju/loggo v0.0.0-20180524022052-584905176618/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= 42 | github.com/juju/mgotest v1.0.1/go.mod h1:vTaDufYul+Ps8D7bgseHjq87X8eu0ivlKLp9mVc/Bfc= 43 | github.com/juju/postgrestest v1.1.0/go.mod h1:/n17Y2T6iFozzXwSCO0JYJ5gSiz2caEtSwAjh/uLXDM= 44 | github.com/juju/qthttptest v0.0.1/go.mod h1://LCf/Ls22/rPw2u1yWukUJvYtfPY4nYpWUl2uZhryo= 45 | github.com/juju/schema v1.0.0/go.mod h1:Y+ThzXpUJ0E7NYYocAbuvJ7vTivXfrof/IfRPq/0abI= 46 | github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA= 47 | github.com/juju/webbrowser v0.0.0-20160309143629-54b8c57083b4 h1:go1FDIXkFL8AUWgJ7B68rtFWCidyrMfZH9x3xwFK74s= 48 | github.com/juju/webbrowser v0.0.0-20160309143629-54b8c57083b4/go.mod h1:G6PCelgkM6cuvyD10iYJsjLBsSadVXtJ+nBxFAxE2BU= 49 | github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= 50 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 51 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= 52 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 53 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 54 | github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 55 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 56 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 57 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 58 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 59 | github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 60 | github.com/lxc/lxd v0.0.0-20200518182231-ee995fa4e26b h1:U+Ecvdvcgl1PgibQWjcy5rpDWDlhFrzcyj4n5/zkztk= 61 | github.com/lxc/lxd v0.0.0-20200518182231-ee995fa4e26b/go.mod h1:2BaZflfwsv8a3uy3/Vw+de4Avn4DSrAiqaHJjCIXMV4= 62 | github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= 63 | github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 64 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 65 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 66 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 67 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 68 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 69 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 70 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af h1:gu+uRPtBe88sKxUCEXRoeCvVG90TJmwhiqRpvdhQFng= 71 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 72 | github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= 73 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 74 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 75 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 76 | github.com/shirou/gopsutil v2.20.5+incompatible h1:tYH07UPoQt0OCQdgWWMgYHy3/a9bcxNpBIysykNIP7I= 77 | github.com/shirou/gopsutil v2.20.5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= 78 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 79 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 80 | github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= 81 | github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= 82 | golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 83 | golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 84 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 85 | golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 86 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= 87 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 88 | golang.org/x/net v0.0.0-20150829230318-ea47fc708ee3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 89 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 90 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= 91 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 92 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 93 | golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 94 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 95 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 96 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= 97 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 98 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 99 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 100 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 101 | golang.org/x/tools v0.0.0-20181008205924-a2b3f7f249e9/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 102 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 103 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 104 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 106 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 107 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 108 | gopkg.in/errgo.v1 v1.0.0/go.mod h1:CxwszS/Xz1C49Ucd2i6Zil5UToP1EmyrFhKaMVbg1mk= 109 | gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso= 110 | gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo= 111 | gopkg.in/httprequest.v1 v1.2.0 h1:YTGV1oXzaoKI6oPzQ0knoIPcrrVzeRG3amkoxoP7Xng= 112 | gopkg.in/httprequest.v1 v1.2.0/go.mod h1:T61ZUaJLpMnzvoJDO03ZD8yRXD4nZzBeDoW5e9sffjg= 113 | gopkg.in/juju/environschema.v1 v1.0.0/go.mod h1:WTgU3KXKCVoO9bMmG/4KHzoaRvLeoxfjArpgd1MGWFA= 114 | gopkg.in/macaroon-bakery.v2 v2.2.0 h1:tgib3W6Nz8GhYfF83vp0FEZmncj6UE1ubIG7a09flkc= 115 | gopkg.in/macaroon-bakery.v2 v2.2.0/go.mod h1:XyHjEinGUBsCK60Qv+bBejOQD/WklvntpSVGja9utaU= 116 | gopkg.in/macaroon.v2 v2.1.0 h1:HZcsjBCzq9t0eBPMKqTN/uSN6JOm78ZJ2INbqcBQOUI= 117 | gopkg.in/macaroon.v2 v2.1.0/go.mod h1:OUb+TQP/OP0WOerC2Jp/3CwhIKyIa9kQjuc7H24e6/o= 118 | gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= 119 | gopkg.in/robfig/cron.v2 v2.0.0-20150107220207-be2e0b0deed5 h1:E846t8CnR+lv5nE+VuiKTDG/v1U2stad0QzddfJC7kY= 120 | gopkg.in/robfig/cron.v2 v2.0.0-20150107220207-be2e0b0deed5/go.mod h1:hiOFpYm0ZJbusNj2ywpbrXowU3G8U6GIQzqn2mw1UIE= 121 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 122 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 123 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 124 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 125 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 126 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 127 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 128 | -------------------------------------------------------------------------------- /service/lxd/runner.go: -------------------------------------------------------------------------------- 1 | package lxd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | lxd "github.com/lxc/lxd/client" 8 | "github.com/lxc/lxd/shared/api" 9 | "github.com/ogra1/fabrica/datastore" 10 | "github.com/ogra1/fabrica/service" 11 | "github.com/ogra1/fabrica/service/system" 12 | "github.com/ogra1/fabrica/service/writecloser" 13 | "io" 14 | "log" 15 | "os" 16 | "path" 17 | "strings" 18 | ) 19 | 20 | var containerEnv = map[string]string{ 21 | "FLASH_KERNEL_SKIP": "true", 22 | "DEBIAN_FRONTEND": "noninteractive", 23 | "TERM": "xterm", 24 | "SNAPCRAFT_BUILD_ENVIRONMENT": "host", 25 | } 26 | 27 | var containerCmd = [][]string{ 28 | {"apt", "update"}, 29 | {"apt", "-y", "upgrade"}, 30 | {"apt", "-y", "install", "build-essential"}, 31 | {"apt", "-y", "clean"}, 32 | {"snap", "install", "snapcraft", "--classic"}, 33 | } 34 | 35 | // runner services to run one build in LXD 36 | type runner struct { 37 | BuildID string 38 | Datastore datastore.Datastore 39 | SystemSrv system.Srv 40 | Connection lxd.InstanceServer 41 | } 42 | 43 | // newRunner creates a new LXD runner 44 | func newRunner(buildID string, ds datastore.Datastore, sysSrv system.Srv, c lxd.InstanceServer) *runner { 45 | return &runner{ 46 | BuildID: buildID, 47 | Datastore: ds, 48 | SystemSrv: sysSrv, 49 | Connection: c, 50 | } 51 | } 52 | 53 | // runBuild launches an LXD container to start the build 54 | func (run *runner) runBuild(name, repo, branch, keyID, distro string) error { 55 | log.Println("Run build:", name, repo, distro) 56 | log.Println("Creating and starting container") 57 | run.Datastore.BuildLogCreate(run.BuildID, "milestone: Creating and starting container") 58 | 59 | // Check if we're in debug mode (retaining the container on error) 60 | debug := run.SystemSrv.SnapCtlGetBool("debug") 61 | 62 | // Generate the container name and store it in the database 63 | cname := containerName(name) 64 | run.Datastore.BuildUpdateContainer(run.BuildID, cname) 65 | 66 | // Create and start the LXD 67 | run.Datastore.BuildLogCreate(run.BuildID, "milestone: Create container "+cname) 68 | if err := run.createAndStartContainer(cname, distro); err != nil { 69 | log.Println("Error creating/starting container:", err) 70 | run.Datastore.BuildLogCreate(run.BuildID, err.Error()) 71 | return err 72 | } 73 | 74 | // Set up the database writer for the logs 75 | dbWC := writecloser.NewDBWriteCloser(run.BuildID, run.Datastore) 76 | 77 | // Wait for the network to be running 78 | run.Datastore.BuildLogCreate(run.BuildID, "milestone: Waiting for the network") 79 | run.waitForNetwork(cname) 80 | run.Datastore.BuildLogCreate(run.BuildID, "milestone: Network is ready") 81 | 82 | // Copy the ssh_config file to the container as .ssh/config 83 | // This has defaults for ssh to handle network issues with accessing various git vendors 84 | if err := run.setSSHConfig(cname, dbWC); err != nil { 85 | log.Println("Error creating ssh config:", err) 86 | run.Datastore.BuildLogCreate(run.BuildID, err.Error()) 87 | return err 88 | } 89 | 90 | // Create the ssh private key file in the container, if needed 91 | passwordNeeded, err := run.setSSHKey(cname, keyID) 92 | if err != nil { 93 | log.Println("Error creating ssh key:", err) 94 | run.Datastore.BuildLogCreate(run.BuildID, err.Error()) 95 | return err 96 | } 97 | 98 | // Create script to clone the repo 99 | if err := run.cloneRepoScript(cname, repo, branch, passwordNeeded); err != nil { 100 | run.Datastore.BuildLogCreate(run.BuildID, err.Error()) 101 | return err 102 | } 103 | 104 | // Install the pre-requisites in the container and clone the repo 105 | // The 'clone' script handles the ssh key and any password 106 | commands := containerCmd 107 | commands = append(containerCmd, []string{"/root/clone"}) 108 | 109 | run.Datastore.BuildLogCreate(run.BuildID, "milestone: Install dependencies") 110 | for _, cmd := range commands { 111 | run.Datastore.BuildLogCreate(run.BuildID, "milestone: "+strings.Join(cmd, " ")) 112 | if err := run.runInContainer(cname, cmd, "", dbWC); err != nil { 113 | log.Println("Command error:", cmd, err) 114 | run.Datastore.BuildLogCreate(run.BuildID, err.Error()) 115 | if !debug { 116 | run.deleteContainer(cname) 117 | } 118 | return err 119 | } 120 | } 121 | 122 | // Set up the download writer for the snap build 123 | dwnWC := writecloser.NewDownloadWriteCloser(run.BuildID, run.Datastore) 124 | 125 | // Run the build using snapcraft 126 | cmd := []string{"snapcraft"} 127 | run.Datastore.BuildLogCreate(run.BuildID, "milestone: Build snap") 128 | if err := run.runInContainer(cname, cmd, "/root/"+name, dwnWC); err != nil { 129 | log.Println("Command error:", cmd, err) 130 | run.Datastore.BuildLogCreate(run.BuildID, err.Error()) 131 | if !debug { 132 | run.deleteContainer(cname) 133 | } 134 | return err 135 | } 136 | 137 | // Download the file from the container 138 | run.Datastore.BuildLogCreate(run.BuildID, fmt.Sprintf("milestone: Download file %s", dwnWC.Filename())) 139 | downloadPath, err := run.copyFile(cname, name, dwnWC.Filename()) 140 | if err != nil { 141 | log.Println("Copy error:", cmd, err) 142 | run.Datastore.BuildLogCreate(run.BuildID, err.Error()) 143 | if !debug { 144 | run.deleteContainer(cname) 145 | } 146 | return err 147 | } 148 | run.Datastore.BuildUpdateDownload(run.BuildID, downloadPath) 149 | 150 | // Remove the container on successful completion 151 | return run.deleteContainer(cname) 152 | } 153 | 154 | // setSSHKey sets up the ssh key in the 155 | func (run *runner) setSSHKey(cname, keyID string) (bool, error) { 156 | if keyID == "" { 157 | return false, nil 158 | } 159 | 160 | // Get the ssh key 161 | key, err := run.Datastore.KeysGet(keyID) 162 | if err != nil { 163 | log.Println("Error fetching ssh key:", err) 164 | return false, err 165 | } 166 | 167 | // Decode the base64-encoded data 168 | data, err := base64.StdEncoding.DecodeString(key.Data) 169 | if err != nil { 170 | log.Println("Error decoding ssh key:", err) 171 | return false, err 172 | } 173 | 174 | // Add the ssh key to the container 175 | if err := run.Connection.CreateContainerFile(cname, "/root/.ssh/id_rsa", lxd.ContainerFileArgs{ 176 | Content: bytes.NewReader(data), 177 | Mode: 0600, 178 | }); err != nil { 179 | return false, fmt.Errorf("error copying ssh key: %v", err) 180 | } 181 | 182 | // Don't need ssh-agent when there is no password on the ssh key, but need a dummy script 183 | if key.Password == "" { 184 | return false, nil 185 | } 186 | 187 | // Write the ssh-key password to a file 188 | if err := run.Connection.CreateContainerFile(cname, "/root/.password", lxd.ContainerFileArgs{ 189 | Content: strings.NewReader(fmt.Sprintf("echo \"%s\"", key.Password)), 190 | Mode: 0700, 191 | }); err != nil { 192 | return true, fmt.Errorf("error saving ssh key password: %v", err) 193 | } 194 | 195 | return true, nil 196 | } 197 | 198 | // setSSHConfig copies the ssh config file to handle ssh defaults for git vendors 199 | func (run *runner) setSSHConfig(cname string, stdOutErr io.WriteCloser) error { 200 | // Create the /root/.ssh directory 201 | if err := run.runInContainer(cname, []string{"mkdir", "-p", "/root/.ssh"}, "", stdOutErr); err != nil { 202 | return fmt.Errorf("error creating .ssh: %v", err) 203 | } 204 | if err := run.runInContainer(cname, []string{"chmod", "700", "/root/.ssh"}, "", stdOutErr); err != nil { 205 | return fmt.Errorf("error setting .ssh permissions: %v", err) 206 | } 207 | 208 | // Get our own config file (provided by the snapcraft layout) 209 | f, err := os.Open("/etc/ssh/ssh_config") 210 | if err != nil { 211 | return fmt.Errorf("error reading /etc/ssh/ssh_config: %v", err) 212 | } 213 | defer f.Close() 214 | 215 | // Add the ssh config file to the container 216 | return run.Connection.CreateContainerFile(cname, "/root/.ssh/config", lxd.ContainerFileArgs{ 217 | Content: f, 218 | Mode: 0644, 219 | }) 220 | } 221 | 222 | // cloneRepoScript creates a script to clone the repo, using ssh-agent to handle keys that need a passphrase 223 | func (run *runner) cloneRepoScript(cname, repo, branch string, passwordNeeded bool) error { 224 | var clone string 225 | if passwordNeeded { 226 | clone = strings.Join( 227 | []string{"#!/bin/sh", 228 | "eval `ssh-agent`", 229 | "export DISPLAY=0", 230 | "export SSH_ASKPASS=/root/.password", 231 | "cat /root/.ssh/id_rsa | ssh-add -", 232 | "git clone -b " + branch + " --progress --depth 1 " + repo, 233 | }, "\n") 234 | } else { 235 | clone = strings.Join( 236 | []string{"#!/bin/sh", 237 | "git clone -b " + branch + " --progress --depth 1 " + repo, 238 | }, "\n") 239 | } 240 | 241 | if err := run.Connection.CreateContainerFile(cname, "/root/clone", lxd.ContainerFileArgs{ 242 | Content: strings.NewReader(clone), 243 | Mode: 0700, 244 | }); err != nil { 245 | return fmt.Errorf("error saving agent script: %v", err) 246 | } 247 | return nil 248 | } 249 | 250 | func (run *runner) deleteContainer(cname string) error { 251 | run.Datastore.BuildLogCreate(run.BuildID, fmt.Sprintf("milestone: Removing container %s", cname)) 252 | if err := stopAndDeleteContainer(run.Connection, cname); err != nil { 253 | run.Datastore.BuildLogCreate(run.BuildID, err.Error()) 254 | return err 255 | } 256 | return nil 257 | } 258 | 259 | func (run *runner) createAndStartContainer(cname, distro string) error { 260 | // Container creation request 261 | req := api.ContainersPost{ 262 | Name: cname, 263 | Source: api.ContainerSource{ 264 | Type: "image", 265 | Alias: "fabrica-" + distro, 266 | }, 267 | } 268 | 269 | // Get LXD to create the container (background operation) 270 | op, err := run.Connection.CreateContainer(req) 271 | if err != nil { 272 | return err 273 | } 274 | 275 | // Wait for the operation to complete 276 | err = op.Wait() 277 | if err != nil { 278 | return err 279 | } 280 | 281 | // Get LXD to start the container (background operation) 282 | reqState := api.ContainerStatePut{ 283 | Action: "start", 284 | Timeout: -1, 285 | } 286 | 287 | op, err = run.Connection.UpdateContainerState(cname, reqState, "") 288 | if err != nil { 289 | return err 290 | } 291 | 292 | // Wait for the operation to complete 293 | return op.Wait() 294 | } 295 | 296 | func (run *runner) waitForNetwork(cname string) { 297 | // Set up the writer to check for a message 298 | wc := writecloser.NewFlagWriteCloser("PING") 299 | 300 | // Run a command in the container 301 | log.Println("Waiting for network...") 302 | cmd := []string{"ping", "-c1", "8.8.8.8"} 303 | for { 304 | _ = run.runInContainer(cname, cmd, "", wc) 305 | if wc.Found() { 306 | break 307 | } 308 | } 309 | } 310 | 311 | func (run *runner) runInContainer(cname string, command []string, cwd string, stdOutErr io.WriteCloser) error { 312 | useDir := "/root" 313 | if cwd != "" { 314 | useDir = cwd 315 | } 316 | 317 | // Setup the exec request 318 | req := api.ContainerExecPost{ 319 | Environment: containerEnv, 320 | Command: command, 321 | WaitForWS: true, 322 | Cwd: useDir, 323 | } 324 | 325 | // Setup the exec arguments (fds) 326 | args := lxd.ContainerExecArgs{ 327 | Stdout: stdOutErr, 328 | Stderr: stdOutErr, 329 | } 330 | 331 | // Get the current state 332 | op, err := run.Connection.ExecContainer(cname, req, &args) 333 | if err != nil { 334 | return err 335 | } 336 | 337 | // Wait for it to complete 338 | return op.Wait() 339 | } 340 | func (run *runner) copyFile(cname, name, filePath string) (string, error) { 341 | // Generate the source path 342 | inFile := path.Join("/root", name, filePath) 343 | 344 | // Generate the destination path 345 | p := service.GetPath(run.BuildID) 346 | _ = os.MkdirAll(p, os.ModePerm) 347 | destFile := path.Join(p, path.Base(filePath)) 348 | outFile, err := os.Create(destFile) 349 | if err != nil { 350 | return "", fmt.Errorf("error creating snap file: %v", err) 351 | } 352 | 353 | // Get the snap file from the container 354 | log.Println("Copy file from:", inFile) 355 | content, _, err := run.Connection.GetContainerFile(cname, inFile) 356 | if err != nil { 357 | return "", fmt.Errorf("error fetching snap file: %v", err) 358 | } 359 | defer content.Close() 360 | 361 | // Copy the file 362 | _, err = io.Copy(outFile, content) 363 | return destFile, err 364 | } 365 | 366 | // stopAndDeleteContainer stops and removes the container 367 | func stopAndDeleteContainer(c lxd.InstanceServer, cname string) error { 368 | // Get LXD to start the container (background operation) 369 | reqState := api.ContainerStatePut{ 370 | Action: "stop", 371 | Timeout: -1, 372 | } 373 | 374 | op, err := c.UpdateContainerState(cname, reqState, "") 375 | if err != nil { 376 | return err 377 | } 378 | 379 | // Wait for the operation to complete 380 | if err := op.Wait(); err != nil { 381 | return err 382 | } 383 | 384 | // Delete the container, but don't wait 385 | _, err = c.DeleteContainer(cname) 386 | return err 387 | } 388 | --------------------------------------------------------------------------------