├── 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 |

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 |

10 |
11 | {props.download ?
12 |
13 |

14 |
15 | :
16 | ''
17 | }
18 |
19 |

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 | | {img.name} |
15 |
16 | {img.available ?
17 |
18 | :
19 |
20 | }
21 | |
22 |
23 |
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 | | {img.name} |
15 |
16 | {img.available ?
17 |
18 | :
19 |
20 | }
21 | |
22 |
23 |
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 |
24 |
25 |
26 | );
27 | }
28 | }
29 |
30 | export default RepoAdd;
--------------------------------------------------------------------------------
/service/lxd/image.go:
--------------------------------------------------------------------------------
1 | package lxd
2 |
3 | import (
4 | "github.com/ogra1/fabrica/domain"
5 | "os/exec"
6 | "syscall"
7 | )
8 |
9 | var plugs = []string{"lxd", "mount-observe", "system-observe", "ssh-keys"}
10 |
11 | // GetImageAlias checks of an image alias is available
12 | func (lx *LXD) GetImageAlias(name string) error {
13 | // Connect to the lxd service
14 | c, err := lx.connect()
15 | if err != nil {
16 | return err
17 | }
18 |
19 | // Check if the alias exists (the image could still be loading)
20 | _, _, err = c.GetImageAlias(name)
21 | return err
22 | }
23 |
24 | // CheckConnections checks the snap interfaces are connected
25 | func (lx *LXD) CheckConnections() []domain.SettingAvailable {
26 | results := []domain.SettingAvailable{}
27 |
28 | for _, p := range plugs {
29 | exitCode := runCommand("snapctl", "is-connected", p)
30 |
31 | // Store the setting
32 | results = append(results, domain.SettingAvailable{
33 | Name: p,
34 | Available: exitCode == 0,
35 | })
36 | }
37 | return results
38 | }
39 |
40 | func runCommand(name string, args ...string) int {
41 | cmd := exec.Command(name, args...)
42 | err := cmd.Run()
43 | if err != nil {
44 | // try to get the exit code
45 | if exitError, ok := err.(*exec.ExitError); ok {
46 | ws := exitError.Sys().(syscall.WaitStatus)
47 | return ws.ExitStatus()
48 | }
49 | } else {
50 | // success, exitCode should be 0 if go is ok
51 | ws := cmd.ProcessState.Sys().(syscall.WaitStatus)
52 | return ws.ExitStatus()
53 | }
54 | return 1
55 | }
56 |
--------------------------------------------------------------------------------
/webapp/src/components/RepoDelete.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Link, Modal} from "@canonical/react-components";
3 | import {T} from "./Utils";
4 |
5 | function RepoDelete(props) {
6 | return (
7 |
8 |
9 | {T('confirm-delete-repo-message') + props.message}
10 |
11 |
12 | {props.deleteBuilds ?
13 |
14 |

{T('delete-builds')}
15 |
16 | :
17 |
18 |

{T('delete-builds')}
19 |
20 | }
21 |
22 |
23 |
24 |
27 |
30 |
31 |
32 | );
33 | }
34 |
35 | export default RepoDelete;
--------------------------------------------------------------------------------
/service/service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "encoding/base64"
5 | "github.com/go-git/go-git/v5/plumbing/transport"
6 | "github.com/go-git/go-git/v5/plumbing/transport/ssh"
7 | "github.com/ogra1/fabrica/domain"
8 | ssh2 "golang.org/x/crypto/ssh"
9 | "log"
10 | "os"
11 | "path"
12 | )
13 |
14 | const (
15 | snapCommon = "SNAP_COMMON"
16 | )
17 |
18 | // GetPath gets a path from SNAP_COMMON
19 | func GetPath(p string) string {
20 | return path.Join(os.Getenv(snapCommon), p)
21 | }
22 |
23 | // GitAuth returns the ssh auth method for git
24 | func GitAuth(key domain.Key, gitURL string) (transport.AuthMethod, error) {
25 | // Decode the private key
26 | var data []byte
27 | data, err := base64.StdEncoding.DecodeString(key.Data)
28 | if err != nil {
29 | log.Println("Error decoding ssh key:", err)
30 | return nil, err
31 | }
32 |
33 | // Get the ssh username from the URL
34 | username, err := usernameFromRepo(gitURL)
35 | if err != nil {
36 | log.Println("Error parsing git URL:", err)
37 | return nil, err
38 | }
39 |
40 | // Set the ssh auth for git
41 | pubKeys, err := ssh.NewPublicKeys(username, data, key.Password)
42 | if err != nil {
43 | log.Println("Error creating ssh key auth:", err)
44 | return nil, err
45 | }
46 |
47 | // Disable the known_hosts check
48 | pubKeys.HostKeyCallback = ssh2.InsecureIgnoreHostKey()
49 | return pubKeys, nil
50 | }
51 |
52 | func usernameFromRepo(u string) (string, error) {
53 | endpoint, err := transport.NewEndpoint(u)
54 | if err != nil {
55 | return "", err
56 | }
57 |
58 | return endpoint.User, nil
59 | }
60 |
--------------------------------------------------------------------------------
/webapp/src/scss/_vanilla-overrides.scss:
--------------------------------------------------------------------------------
1 | /**
2 | * Vanilla overrides.
3 | * {Date} {Person}: {Description of issue}
4 | * {Link to relevant GitHub issue}
5 | */
6 |
7 | // 09-12-2019 Huw: Truncate does not show ellipsis with expanding tables.
8 | // https://github.com/canonical-web-and-design/vanilla-framework/issues/2697
9 | .p-table-expanding td.u-truncate {
10 | display: block;
11 | }
12 |
13 | // 27-11-2019 Caleb: Tooltips cannot be used with React Portals.
14 | // https://github.com/canonical-web-and-design/vanilla-framework/issues/2672
15 | .p-tooltip__message--portal {
16 | display: inline;
17 | }
18 |
19 | // 03-02-2020 Caleb: Cannot give label a className when using Input component.
20 | // https://github.com/canonical-web-and-design/react-components/issues/67
21 | input[type="checkbox"],
22 | input[type="radio"] {
23 | &.has-inline-label + label {
24 | display: inline;
25 | padding-top: 0;
26 |
27 | &::before {
28 | left: 0rem;
29 | top: 0.125rem;
30 | }
31 |
32 | &::after {
33 | left: 0.1875rem;
34 | top: 0.375rem;
35 | }
36 | }
37 | }
38 |
39 | // 10-03-2020 Caleb: Spacing around checkboxes/radios too tight when $multi = 1
40 | // https://github.com/canonical-web-and-design/vanilla-framework/issues/2913
41 | input[type="checkbox"],
42 | input[type="radio"] {
43 | + label {
44 | margin-bottom: 0.6rem;
45 |
46 | .p-list__item:not(:first-child) &,
47 | .p-form__group:not(:first-child) & {
48 | margin-top: -0.5rem;
49 | }
50 | }
51 | }
52 | .p-form-help-text,
53 | .p-form-validation__message {
54 | margin-bottom: 0.8rem;
55 | margin-top: -0.5rem;
56 | }
--------------------------------------------------------------------------------
/fabrica.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "github.com/ogra1/fabrica/config"
6 | "github.com/ogra1/fabrica/datastore"
7 | "github.com/ogra1/fabrica/datastore/sqlite"
8 | "github.com/ogra1/fabrica/service"
9 | "github.com/ogra1/fabrica/service/key"
10 | "github.com/ogra1/fabrica/service/lxd"
11 | "github.com/ogra1/fabrica/service/repo"
12 | "github.com/ogra1/fabrica/service/system"
13 | "github.com/ogra1/fabrica/service/watch"
14 | "github.com/ogra1/fabrica/web"
15 | "os"
16 | )
17 |
18 | func main() {
19 | var mode string
20 | flag.StringVar(&mode, "mode", "web", "Mode of operation: web, watch, keygen")
21 | flag.Parse()
22 |
23 | if mode == "keygen" {
24 | service.GenerateHostKey()
25 | os.Exit(0)
26 | }
27 |
28 | settings := config.ReadParameters()
29 |
30 | // Set up the dependency chain
31 | db, _ := sqlite.NewDatabase()
32 | systemSrv := system.NewSystemService(db)
33 | lxdSrv := lxd.NewLXD(db, systemSrv)
34 | buildSrv := repo.NewBuildService(db, lxdSrv)
35 | keySrv := key.NewKeyService(db)
36 |
37 | // Set up the service based on the mode
38 | if mode == "watch" {
39 | watchDaemon(db, buildSrv, keySrv)
40 | } else {
41 | webService(settings, buildSrv, lxdSrv, systemSrv, keySrv)
42 | }
43 | }
44 |
45 | func webService(settings *config.Settings, buildSrv *repo.BuildService, lxdSrv lxd.Service, systemSrv system.Srv, keySrv key.Srv) {
46 | srv := web.NewWebService(settings, buildSrv, lxdSrv, systemSrv, keySrv)
47 | srv.Start()
48 | }
49 |
50 | func watchDaemon(db datastore.Datastore, buildSrv repo.BuildSrv, keySrv key.Srv) {
51 | watchSrv := watch.NewWatchService(db, buildSrv, keySrv)
52 | watchSrv.Watch()
53 | }
54 |
--------------------------------------------------------------------------------
/bin/init.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 |
3 | import os
4 | import platform
5 | import shutil
6 | import sys
7 | import warnings
8 | from pylxd import Client
9 |
10 | client = Client()
11 |
12 | warnings.filterwarnings("ignore")
13 |
14 |
15 | def convert(num):
16 | unit = 1000.0
17 | for x in ['', 'KB', 'MB', 'GB', 'TB']:
18 | if num < unit:
19 | return "%.0f%s" % (num, x)
20 | num /= unit
21 |
22 |
23 | def get_driver():
24 | machine = platform.machine().lower()
25 | if machine.startswith(('arm', 'aarch64')):
26 | return "btrfs"
27 | return "zfs"
28 |
29 |
30 | def create_storage():
31 | snap_data = os.environ['SNAP_DATA']
32 | free = ( shutil.disk_usage(snap_data)[-1] / 10 ) * 6
33 |
34 | config = { "config": { "size": convert(free) },
35 | "driver": get_driver(), "name": "default" }
36 |
37 | try:
38 | client.storage_pools.get('default')
39 | except:
40 | try:
41 | client.storage_pools.create(config)
42 | except Exception as ex:
43 | print(ex)
44 | sys.exit(1)
45 |
46 |
47 | def init_image(name):
48 | try:
49 | if client.images.get_by_alias('fabrica-'+name):
50 | print('Image: ' + 'fabrica-' + name + ' already exists')
51 | return
52 | except:
53 | print('Creating master image: ' + name)
54 |
55 | image = client.images.create_from_simplestreams(
56 | 'https://cloud-images.ubuntu.com/daily',
57 | name)
58 | image.add_alias('fabrica-'+name, '')
59 |
60 |
61 | def main():
62 | create_storage()
63 | for img in ['focal', 'bionic', 'xenial']:
64 | init_image(img)
65 |
66 | main()
67 |
--------------------------------------------------------------------------------
/web/key.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/gorilla/mux"
6 | "github.com/ogra1/fabrica/domain"
7 | "io"
8 | "net/http"
9 | )
10 |
11 | // KeyCreate store a new ssh key
12 | func (srv Web) KeyCreate(w http.ResponseWriter, r *http.Request) {
13 | req := srv.decodeKeyRequest(w, r)
14 | if req == nil {
15 | return
16 | }
17 |
18 | keyID, err := srv.KeySrv.Create(req.Name, req.Data, req.Password)
19 | if err != nil {
20 | formatStandardResponse("key", err.Error(), w)
21 | return
22 | }
23 |
24 | formatStandardResponse("", keyID, w)
25 | }
26 |
27 | // KeyList lists the ssh keys
28 | func (srv Web) KeyList(w http.ResponseWriter, r *http.Request) {
29 | records, err := srv.KeySrv.List()
30 | if err != nil {
31 | formatStandardResponse("list", err.Error(), w)
32 | return
33 | }
34 |
35 | formatRecordsResponse(records, w)
36 | }
37 |
38 | // KeyDelete removes an unused key
39 | func (srv Web) KeyDelete(w http.ResponseWriter, r *http.Request) {
40 | vars := mux.Vars(r)
41 |
42 | // Delete the repo
43 | if err := srv.KeySrv.Delete(vars["id"]); err != nil {
44 | formatStandardResponse("key", err.Error(), w)
45 | return
46 | }
47 |
48 | formatStandardResponse("", "", w)
49 | }
50 |
51 | func (srv Web) decodeKeyRequest(w http.ResponseWriter, r *http.Request) *domain.Key {
52 | // Decode the JSON body
53 | req := domain.Key{}
54 | err := json.NewDecoder(r.Body).Decode(&req)
55 | switch {
56 | // Check we have some data
57 | case err == io.EOF:
58 | formatStandardResponse("data", "No request data supplied.", w)
59 | return nil
60 | // Check for parsing errors
61 | case err != nil:
62 | formatStandardResponse("decode-json", err.Error(), w)
63 | return nil
64 | }
65 | return &req
66 | }
67 |
--------------------------------------------------------------------------------
/datastore/sqlite/buildlog.go:
--------------------------------------------------------------------------------
1 | package sqlite
2 |
3 | import (
4 | "github.com/ogra1/fabrica/domain"
5 | "github.com/rs/xid"
6 | )
7 |
8 | const createBuildLogTableSQL string = `
9 | CREATE TABLE IF NOT EXISTS buildlog (
10 | id varchar(200) primary key not null,
11 | build_id varchar(200) not null,
12 | message text,
13 | created timestamp default current_timestamp,
14 | FOREIGN KEY (build_id) REFERENCES build (id)
15 | )
16 | `
17 | const addBuildLogSQL = `
18 | INSERT INTO buildlog(id, build_id, message) VALUES ($1, $2, $3)
19 | `
20 | const listBuildLogSQL = `
21 | SELECT id, build_id, message, created
22 | FROM buildlog
23 | WHERE build_id=$1
24 | ORDER BY created
25 | `
26 | const deleteBuildLogsSQL = `
27 | DELETE FROM buildlog WHERE build_id=$1
28 | `
29 |
30 | // BuildLogCreate logs a message for a build
31 | func (db *DB) BuildLogCreate(buildID, message string) error {
32 | id := xid.New()
33 | _, err := db.Exec(addBuildLogSQL, id.String(), buildID, message)
34 | return err
35 | }
36 |
37 | // BuildLogList lists messages for a build
38 | func (db *DB) BuildLogList(buildID string) ([]domain.BuildLog, error) {
39 | logs := []domain.BuildLog{}
40 | rows, err := db.Query(listBuildLogSQL, buildID)
41 | if err != nil {
42 | return logs, err
43 | }
44 | defer rows.Close()
45 |
46 | for rows.Next() {
47 | r := domain.BuildLog{}
48 | err := rows.Scan(&r.ID, &r.BuildID, &r.Message, &r.Created)
49 | if err != nil {
50 | return logs, err
51 | }
52 | logs = append(logs, r)
53 | }
54 |
55 | return logs, nil
56 | }
57 |
58 | // BuildLogDelete deletes logs for a build
59 | func (db *DB) BuildLogDelete(buildID string) error {
60 | _, err := db.Exec(deleteBuildLogsSQL, buildID)
61 | return err
62 | }
63 |
--------------------------------------------------------------------------------
/webapp/src/components/Messages.js:
--------------------------------------------------------------------------------
1 | export default {
2 | 'add': 'Add',
3 | 'add-key': 'Add ssh key',
4 | 'add-repo': 'Add repository',
5 | 'app-title': 'Fabrica - snap builder',
6 | 'branch': 'Branch',
7 | 'build': 'Build',
8 | 'build-log': 'Build Log',
9 | 'build-requests': 'Latest builds',
10 | 'cancel': 'Cancel',
11 | 'check-connections': 'Ensure all the interfaces are connected with `snap connect`',
12 | 'complete': 'Complete',
13 | 'confirm-delete': 'Confirm delete',
14 | 'confirm-delete-message': 'Are you sure you want to delete the build: ',
15 | 'confirm-delete-repo-message': 'Are you sure you want to delete the repo: ',
16 | 'contribute': 'Contribute',
17 | 'created': 'Created',
18 | 'delete': 'Delete',
19 | 'delete-builds': 'Delete builds for this repo',
20 | 'getting-ready': 'Getting ready for lift off...',
21 | 'git-branch': 'Git branch',
22 | 'git-repo': 'Git repo',
23 | 'in-progress': 'In progress',
24 | 'key-list': 'SSH keys',
25 | 'key-name': 'Key name',
26 | 'key-name-help': 'descriptive name for the key',
27 | 'last-commit': 'Last commit',
28 | 'loading-images': 'Waiting for LXD images...',
29 | 'name': 'Name',
30 | 'private-key': 'Private key',
31 | 'private-key-password': 'Private key password',
32 | 'private-key-password-help': 'password to unlock the private key',
33 | 'repo': 'Repository',
34 | 'repo-key': 'Repo ssh key',
35 | 'repo-list': 'Watched repositories',
36 | 'report-bug': 'Report a bug',
37 | 'scroll-off': 'Auto-scroll Off',
38 | 'scroll-on': 'Auto-scroll On',
39 | 'settings': 'Settings',
40 | 'status': 'Status',
41 | 'username-repo': 'Username for repo',
42 | 'username-repo-help': 'username to access the remote repo',
43 | 'view': 'View',
44 | }
--------------------------------------------------------------------------------
/service/repo/repo.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | import (
4 | "fmt"
5 | "github.com/ogra1/fabrica/domain"
6 | "log"
7 | "strings"
8 | )
9 |
10 | // RepoCreate creates a new repo
11 | func (bld *BuildService) RepoCreate(repo, branch, keyID string) (string, error) {
12 | // Handle Launchpad git URLs
13 | if strings.HasPrefix(repo, "git+ssh") {
14 | repo = strings.Replace(repo, "git+ssh", "ssh", 1)
15 | }
16 |
17 | // Store the build request
18 | name := nameFromRepo(repo)
19 | repoID, err := bld.Datastore.RepoCreate(name, repo, branch, keyID)
20 | if err != nil {
21 | return repoID, fmt.Errorf("error storing repo: %v", err)
22 | }
23 |
24 | return repoID, nil
25 | }
26 |
27 | // RepoList returns a list of repos
28 | func (bld *BuildService) RepoList(watch bool) ([]domain.Repo, error) {
29 | return bld.Datastore.RepoList(watch)
30 | }
31 |
32 | // RepoDelete removes a repo and optionally removes its builds
33 | func (bld *BuildService) RepoDelete(id string, deleteBuilds bool) error {
34 | log.Println("Repo Delete:", id, deleteBuilds)
35 |
36 | if !deleteBuilds {
37 | // Just remove the repo record
38 | return bld.Datastore.RepoDelete(id)
39 | }
40 |
41 | // Get the repo name from its ID
42 | repo, err := bld.Datastore.RepoGet(id)
43 | if err != nil {
44 | return fmt.Errorf("cannot find repo: %v", err)
45 | }
46 |
47 | // Get the builds for this repo
48 | builds, err := bld.Datastore.BuildListForRepo(repo.Repo, repo.Branch)
49 | if err != nil {
50 | return fmt.Errorf("error finding builds: %v", err)
51 | }
52 |
53 | // Remove each of the repo's builds
54 | for _, b := range builds {
55 | if err := bld.BuildDelete(b.ID); err != nil {
56 | return fmt.Errorf("error removing build (%s): %v", b.ID, err)
57 | }
58 | }
59 |
60 | // Remove the repo record
61 | return bld.Datastore.RepoDelete(id)
62 | }
63 |
--------------------------------------------------------------------------------
/service/writecloser/downloadwrite.go:
--------------------------------------------------------------------------------
1 | package writecloser
2 |
3 | import (
4 | "github.com/ogra1/fabrica/datastore"
5 | "log"
6 | "strings"
7 | "sync"
8 | )
9 |
10 | // DownloadWriteCloser writes log lines to the database
11 | type DownloadWriteCloser struct {
12 | lock sync.RWMutex
13 | BuildID string
14 | Datastore datastore.Datastore
15 | filename string
16 | }
17 |
18 | // NewDownloadWriteCloser creates a new database write-closer
19 | func NewDownloadWriteCloser(buildID string, ds datastore.Datastore) *DownloadWriteCloser {
20 | return &DownloadWriteCloser{
21 | BuildID: buildID,
22 | Datastore: ds,
23 | }
24 | }
25 |
26 | // Write writes a log message to the database
27 | func (dwn *DownloadWriteCloser) Write(b []byte) (int, error) {
28 | dwn.lock.Lock()
29 | defer dwn.lock.Unlock()
30 |
31 | s := string(b)
32 |
33 | // Check if we have the snapped line
34 | log.Println(s)
35 | if strings.Contains(s, "Snapped") {
36 | parts := strings.Split(s, " ")
37 | if len(parts) > 0 {
38 | dwn.filename = dwn.cleanFilename(parts[1])
39 | }
40 | }
41 |
42 | if err := dwn.Datastore.BuildLogCreate(dwn.BuildID, s); err != nil {
43 | return 0, err
44 | }
45 | return len(b), nil
46 | }
47 |
48 | func (dwn *DownloadWriteCloser) cleanFilename(filePath string) string {
49 | // Get the snap file name (without the extra text at the end)
50 | parts := strings.Split(filePath, ".snap")
51 | name := parts[0] + ".snap"
52 | return strings.Trim(name, "'")
53 | }
54 |
55 | // Close is a noop to fulfill the interface
56 | func (dwn *DownloadWriteCloser) Close() error {
57 | // Noop
58 | return nil
59 | }
60 |
61 | // Filename retrieves the filename of the snap
62 | func (dwn *DownloadWriteCloser) Filename() string {
63 | dwn.lock.RLock()
64 | defer dwn.lock.RUnlock()
65 |
66 | return dwn.filename
67 | }
68 |
--------------------------------------------------------------------------------
/webapp/src/components/KeysAdd.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Button, Card, Form, Input, Row} from "@canonical/react-components";
3 | import {T} from "./Utils";
4 |
5 | class KeysAdd extends Component {
6 | onChangeName = (e) => {
7 | e.preventDefault()
8 | this.props.onChange('name', e.target.value)
9 | }
10 | onChangeFile = (e) => {
11 | e.preventDefault()
12 |
13 | let reader = new FileReader();
14 | let file = e.target.files[0];
15 |
16 | reader.onload = (upload) => {
17 | this.props.onChange('data', upload.target.result.split(',')[1])
18 | }
19 |
20 | reader.readAsDataURL(file);
21 | }
22 | onChangePassword = (e) => {
23 | e.preventDefault()
24 | this.props.onChange('password', e.target.value)
25 | }
26 |
27 | render() {
28 | console.log(this.props.record)
29 | return (
30 |
31 |
32 |
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 |
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 |
--------------------------------------------------------------------------------