refactored the api (fixes #6)

pull/38/head
Mike Heijmans 2017-08-02 22:06:18 -05:00
parent b7cc3f0083
commit d12cdec3b8
18 changed files with 381 additions and 175 deletions

View File

@ -17,7 +17,8 @@ gem 'rake'
# Component requirements # Component requirements
gem 'slim' gem 'slim'
gem 'sinatra' gem 'sinatra', '~> 1.4.5'
gem 'rack-protection', '~> 1.5.3'
gem 'rack', '~> 1.6.0' gem 'rack', '~> 1.6.0'
@ -28,3 +29,5 @@ gem 'pry'
gem 'memoist' gem 'memoist'
gem "sinatra-cross_origin", "~> 0.3.1" gem "sinatra-cross_origin", "~> 0.3.1"
gem 'sinatra-session'

View File

@ -29,6 +29,8 @@ GEM
rack-protection (~> 1.4) rack-protection (~> 1.4)
tilt (>= 1.3, < 3) tilt (>= 1.3, < 3)
sinatra-cross_origin (0.3.2) sinatra-cross_origin (0.3.2)
sinatra-session (1.0.0)
sinatra (>= 1.0)
slim (3.0.6) slim (3.0.6)
temple (~> 0.7.3) temple (~> 0.7.3)
tilt (>= 1.3.3, < 2.1) tilt (>= 1.3.3, < 2.1)
@ -55,9 +57,11 @@ DEPENDENCIES
oj oj
pry pry
rack (~> 1.6.0) rack (~> 1.6.0)
rack-protection (~> 1.5.3)
rake rake
sinatra sinatra (~> 1.4.5)
sinatra-cross_origin (~> 0.3.1) sinatra-cross_origin (~> 0.3.1)
sinatra-session
slim slim
thin thin
unicorn unicorn
@ -66,4 +70,4 @@ RUBY VERSION
ruby 2.3.1p112 ruby 2.3.1p112
BUNDLED WITH BUNDLED WITH
1.13.7 1.14.6

View File

@ -1 +1 @@
2.1 2.2

View File

@ -1 +0,0 @@
apiserver.rb

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import RepoBrowser from './components/RepoBrowser' import AppContainer from './views/AppContainer.jsx';
ReactDOM.render( ReactDOM.render(
<RepoBrowser />, <AppContainer/>,
document.getElementById('app') document.getElementById('app')
); );

25
app/components/Login.jsx Normal file
View File

@ -0,0 +1,25 @@
import React, { Component } from 'react';
import { NavDropdown, NavItem, MenuItem } from 'react-bootstrap';
import { Link } from 'react-router-dom';
export default class Login extends Component {
displayLogin(){
if (this.props.registry.username){
return(
<NavDropdown eventKey={this.props.eventKey} title={this.props.registry.username} id="login-dropdown">
<MenuItem eventKey={this.props.eventKey + 0.1} href="/logout">Logout</MenuItem>
</NavDropdown>
)
}
return(
<NavItem eventKey={this.props.eventKey}>Login</NavItem>
)
}
render() {
return (
this.displayLogin()
);
}
}

View File

@ -78,8 +78,6 @@ class RepoBrowser extends React.Component {
render(){ render(){
return( return(
<div className="main-container">
<Header />
<div className="container"> <div className="container">
<div className="col-sm-3"> <div className="col-sm-3">
<Repos repo={this.state.repo} setRepo={(name) => this.handleSetRepo(name)}/> <Repos repo={this.state.repo} setRepo={(name) => this.handleSetRepo(name)}/>
@ -91,8 +89,6 @@ class RepoBrowser extends React.Component {
<TagInfo tag={this.state.tag} repo={this.state.repo} handleTagDelete={(repo, tag) => this.handleTagDelete(repo, tag)} getinfo={this.state.getinfo} registry={this.state.registry}/> <TagInfo tag={this.state.tag} repo={this.state.repo} handleTagDelete={(repo, tag) => this.handleTagDelete(repo, tag)} getinfo={this.state.getinfo} registry={this.state.registry}/>
</div> </div>
</div> </div>
<Footer registry={this.state.registry} />
</div>
)}; )};
} }

View File

@ -1,20 +0,0 @@
import React from 'react';
import { Navbar } from 'react-bootstrap';
export default class Header extends React.Component {
render(){
return (
<div>
<Navbar staticTop={true}>
<Navbar.Header>
<img className='navbar-brand' src="mini-logo.svg"/>
<Navbar.Brand>
Crane Operator
</Navbar.Brand>
</Navbar.Header>
</Navbar>
</div>
);
}
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import axios from 'axios';
import {
BrowserRouter as Router,
Route,
Link
} from 'react-router-dom'
import HomeView from './HomeView.jsx';
import LoginView from './LoginView.jsx';
import Header from './sections/Header.jsx';
import Footer from './sections/Footer.jsx';
export default class AppContainer extends React.Component {
constructor(props){
super(props);
this.state = {
registry: {}
}
}
fetchRegistryInfo(){
return axios.get(`/api/registryinfo`)
.then(function (response) {
this.setState({
registry: response.data
})
}.bind(this))
.catch(function (response) {
console.log('ERROR IN AXIOS! ' + response);
});
};
componentDidMount() {
this.fetchRegistryInfo()
}
render() {
return (
<Router>
<div>
<Header registry={this.state.registry}/>
<div className="container">
<Route exact path="/" component={HomeView}/>
<Route exact path="/login" component={LoginView}/>
</div>
<Footer registry={this.state.registry}/>
</div>
</Router>
);
}
}

20
app/views/HomeView.jsx Normal file
View File

@ -0,0 +1,20 @@
import React, { Component } from 'react';
export default class HomeView extends Component {
constructor(props){
super(props);
this.state = {
loaded: false,
term: ""
}
}
render() {
return (
<div>
<h1>Home</h1>
</div>
);
}
}

55
app/views/LoginView.jsx Normal file
View File

@ -0,0 +1,55 @@
import React, { Component } from 'react';
import { FormControl, FormGroup, ControlLabel, Col, Row } from 'react-bootstrap';
export default class LoginView extends Component {
constructor(props){
super(props);
this.state = {
username: undefined,
password: undefined
}
}
handleUsername(event){
this.setState({
username: event.target.value
})
}
handlePassword(event){
this.setState({
password: event.target.value
})
}
render() {
return (
<div>
<h1>Login to Docker Registry</h1>
<Row>
<form>
<Col md={4}>
<Row style={{paddingBottom: "1em", paddingTop: "2em"}}>
<Col sm={12}>
<FormControl type="text"
placeholder="Username"
onChange={(event) => this.handleUsername(event)}
/>
</Col>
</Row>
<Row>
<Col sm={12}>
<FormControl type="password"
placeholder="Password"
onChange={(event) => this.handlePassword(event)}
/>
</Col>
</Row>
</Col>
</form>
</Row>
</div>
);
}
}

View File

@ -19,7 +19,3 @@ export default class Footer extends React.Component {
); );
} }
} }
Footer.propTypes = {
registry: React.PropTypes.object.isRequired
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Link } from 'react-router-dom'
import { Navbar, Nav, NavItem } from 'react-bootstrap';
import Login from '../../components/Login.jsx';
export default class Header extends React.Component {
title(){
if(this.props.registry.title){
return(this.props.registry.title)
}
return("Crane Operator")
};
render(){
return (
<div>
<Navbar staticTop={true}>
<Navbar.Header>
<img className='navbar-brand' src="mini-logo.svg"/>
<Navbar.Brand>
<Link to="/">{this.title()}</Link>
</Navbar.Brand>
</Navbar.Header>
<Nav pullRight>
<Login registry={this.props.registry} eventKey={1}/>
</Nav>
</Navbar>
</div>
);
}
}

37
lib/config.rb Normal file
View File

@ -0,0 +1,37 @@
class Configuration
attr_accessor :registry_password,
:registry_username,
:registry_host,
:registry_port,
:registry_protocol,
:ssl_verify,
:registry_public_url,
:delete_allowed,
:username,
:password,
:version
def initialize
@registry_username = ENV['REGISTRY_USERNAME']
@registry_password = ENV['REGISTRY_PASSWORD']
@registry_host = ENV['REGISTRY_HOST'] || 'localhost'
@registry_port = ENV['REGISTRY_PORT'] || '5000'
@registry_protocol = ENV['REGISTRY_PROTOCOL'] || 'https'
@registry_public_url = ENV['REGISTRY_PUBLIC_URL'] || "#{@registry_host}:#{@registry_port}"
@ssl_verify = to_bool(ENV['SSL_VERIFY'] || 'true')
@delete_allowed = to_bool(ENV['REGISTRY_ALLOW_DELETE'] || 'false')
@username = ENV['USERNAME']
@password = ENV['PASSWORD']
@version = "2.2"
end
def to_bool(str)
str.to_s.downcase == 'true'
end
def registry_url
"#{registry_protocol}://#{registry_host}:#{registry_port}"
end
end

55
lib/helpers.rb Normal file
View File

@ -0,0 +1,55 @@
require 'base64'
require 'oj'
require 'httparty'
module Helpers
def sort_versions(ary)
valid_version_numbers = ary.select { |i| i if i.match(/^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+(-[[:alnum:]]+)?$/) }
non_valid_version_numbers = ary - valid_version_numbers
versions = valid_version_numbers.sort_by {|v| Gem::Version.new( v.gsub(/^[a-z|A-Z|.]*/, '') ) } + non_valid_version_numbers.sort
if versions.include?('latest')
# Make sure 'latest' appears at the top of the list
versions.delete('latest')
versions.push('latest')
end
versions
end
def to_bool(str)
str.to_s.downcase == 'true'
end
def html(view)
File.read(File.join('public', "#{view.to_s}.html"))
end
def generateHeaders(config, session, headers={})
username = session[:username] || config.registry_username
password = session[:password] || config.registry_password
if username
headers['Authorization'] = "Basic #{base64_docker_auth(username, password)}"
end
end
def base64_docker_auth(username, password)
Base64.encode64("#{username}:#{password}").chomp
end
def append_header(headers, addl_header)
headers.merge addl_header
end
def get(url, config, session, headers={})
response = HTTParty.get( "#{config.registry_url}#{url}", verify: config.ssl_verify, headers: generateHeaders(config, session, headers) )
Oj.load response.body
end
def get_head(url, config, session, headers={})
HTTParty.head( "#{registry_url}#{url}", verify: config.ssl_verify, headers: generateHeaders(config, session, headers) )
end
def send_delete(url, config, session, headers={})
HTTParty.delete( "#{registry_url}#{url}", verify: config.ssl_verify, headers: generateHeaders(config, session, headers) )
end
end

View File

@ -5,24 +5,24 @@
"main": "bundle.js", "main": "bundle.js",
"dependencies": { "dependencies": {
"axios": "^0.8.0", "axios": "^0.8.0",
"history": "^1.13.1",
"moment": "^2.13.0",
"re-base": "^1.5.1",
"react": "^0.14.3",
"react-bootstrap": "^0.29.3",
"react-datetime": "^2.1.0",
"react-dom": "^0.14.3",
"react-intl": "^2.1.2",
"react-loader": "^2.4.0",
"react-router": "^1.0.1",
"react-time": "^4.0.3",
"babel-core": "^6.3.13", "babel-core": "^6.3.13",
"babel-loader": "^6.2.0", "babel-loader": "^6.2.0",
"babel-preset-es2015": "^6.3.13", "babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13", "babel-preset-react": "^6.3.13",
"webpack": "^1.13.0", "esprima-fb": "15001.1001.0-dev-harmony-fb",
"history": "^1.13.1",
"moment": "^2.13.0",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"esprima-fb": "15001.1001.0-dev-harmony-fb" "re-base": "^1.5.1",
"react": "^15.6.1",
"react-datetime": "^2.1.0",
"react-dom": "^15.6.1",
"react-intl": "^2.1.2",
"react-loader": "^2.4.0",
"react-router": "^1.0.3",
"react-router-dom": "^4.1.2",
"react-time": "^4.0.3",
"webpack": "^1.13.0"
}, },
"devDependencies": { "devDependencies": {
"webpack-dev-server": "^1.14.1" "webpack-dev-server": "^1.14.1"

View File

@ -11,23 +11,23 @@
<link rel="stylesheet" href="/bootstrap-theme.css" crossorigin="anonymous"> <link rel="stylesheet" href="/bootstrap-theme.css" crossorigin="anonymous">
<!-- font awesome --> <!-- font awesome -->
<link rel="stylesheet" href="css/font-awesome.min.css" crossorigin="anonymous"> <link rel="stylesheet" href="/css/font-awesome.min.css" crossorigin="anonymous">
<!-- application css --> <!-- application css -->
<link rel="stylesheet" href="/app.css" crossorigin="anonymous"> <link rel="stylesheet" href="/app.css" crossorigin="anonymous">
<!-- jquery --> <!-- jquery -->
<script src="jquery-3.1.0.min.js"></script> <script src="/jquery-3.1.0.min.js"></script>
<!-- bootstrap js --> <!-- bootstrap js -->
<script src="bootstrap.min.js"></script> <script src="/bootstrap.min.js"></script>
<!-- bootbox --> <!-- bootbox -->
<script src="bootbox.min.js"></script> <script src="/bootbox.min.js"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script src="bundle.js"></script> <script src="/bundle.js"></script>
</body> </body>
</html> </html>

193
server.rb
View File

@ -1,17 +1,19 @@
require 'oj' require 'oj'
require 'httparty'
require 'pry' require 'pry'
require 'erb' require 'erb'
require 'time' require 'time'
require 'sinatra/base' require 'sinatra/base'
require 'sinatra/cross_origin' require 'sinatra/cross_origin'
require 'base64' require './lib/config.rb'
require './lib/helpers.rb'
class CraneOp < Sinatra::Base class CraneOp < Sinatra::Base
register Sinatra::CrossOrigin register Sinatra::CrossOrigin
include Helpers
configure do configure do
enable :cross_origin enable :cross_origin
enable :sessions
mime_type :javascript, 'application/javascript' mime_type :javascript, 'application/javascript'
mime_type :javascript, 'text/javascript' mime_type :javascript, 'text/javascript'
set :logging, true set :logging, true
@ -22,118 +24,43 @@ class CraneOp < Sinatra::Base
set :max_age, "1728000" set :max_age, "1728000"
set :expose_headers, ['Content-Type'] set :expose_headers, ['Content-Type']
set :json_encoder, :to_json set :json_encoder, :to_json
set :session_secret, (ENV["SESSION_SECRET"] || "insecure-session-secret!")
end end
## Setup ## def conf
return Configuration.new
def registry_host
ENV['REGISTRY_HOST'] || 'localhost'
end end
def registry_port ## Basic Auth ##
ENV['REGISTRY_PORT'] || '5000'
end
def registry_proto if Configuration.new.username
ENV['REGISTRY_PROTO'] || 'https' config = Configuration.new
end
def registry_ssl_verify
ENV['REGISTRY_SSL_VERIFY'] || 'true'
end
def registry_public_url
ENV['REGISTRY_PUBLIC_URL'] || "#{registry_host}:#{registry_port}"
end
def registry_username
ENV['REGISTRY_USERNAME']
end
def registry_password
ENV['REGISTRY_PASSWORD']
end
def delete_allowed
ENV['REGISTRY_ALLOW_DELETE'] || 'false'
end
## Authentication ##
if ENV['USERNAME']
use Rack::Auth::Basic, "Please Authenticate to View" do |username, password| use Rack::Auth::Basic, "Please Authenticate to View" do |username, password|
username == ENV['USERNAME'] and password == ( ENV['PASSWORD'] || '' ) username == config.username and password == ( config.password || '' )
end end
end end
def base64_docker_auth
Base64.encode64("#{registry_username}:#{registry_password}").chomp
end
def hdrs
h = {}
if registry_username
h['Authorization'] = "Basic #{base64_docker_auth}"
end
return h
end
def append_header(h, addl_header)
h.merge addl_header
end
## Helpers ##
def to_bool(str)
str.to_s.downcase == 'true'
end
def html(view)
File.read(File.join('public', "#{view.to_s}.html"))
end
def sort_versions(ary)
valid_version_numbers = ary.select { |i| i if i.match(/^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+(-[[:alnum:]]+)?$/) }
non_valid_version_numbers = ary - valid_version_numbers
versions = valid_version_numbers.sort_by {|v| Gem::Version.new( v.gsub(/^[a-z|A-Z|.]*/, '') ) } + non_valid_version_numbers.sort
if versions.include?('latest')
# Make sure 'latest' appears at the top of the list
versions.delete('latest')
versions.push('latest')
end
versions
end
def registry_url
url_parts = []
url_parts << registry_proto
url_parts << "://"
url_parts << registry_host
url_parts << ":"
url_parts << registry_port
url_parts.join
end
## Registry API Methods ## ## Registry API Methods ##
def containers def containers(filter=nil)
response = HTTParty.get( "#{registry_url}/v2/_catalog", verify: to_bool(registry_ssl_verify), headers: hdrs ) json = get("/v2/_catalog", conf, session)
json = Oj.load response.body if filter
return json['repositories'].select{ |i| i.match(/#{filter}.*/)}
end
json['repositories'] json['repositories']
end end
def container_tags(repo) def container_tags(repo, filter=nil)
response = HTTParty.get( "#{registry_url}/v2/#{repo}/tags/list", verify: to_bool(registry_ssl_verify), headers: hdrs ) json = get("/v2/#{repo}/tags/list", conf, session)
json = Oj.load response.body
tags = json['tags'] || [] tags = json['tags'] || []
tags = sort_versions(tags).reverse if filter
return sort_versions(tags.select{ |i| i.match(/#{filter}.*/)}).reverse
end
sort_versions(tags).reverse
end end
def container_info(repo, manifest) def container_info(repo, manifest)
response = HTTParty.get( "#{registry_url}/v2/#{repo}/manifests/#{manifest}", verify: to_bool(registry_ssl_verify), headers: hdrs ) json = get("/v2/#{repo}/manifests/#{manifest}", conf, session)
json = Oj.load response.body
# Add extra fields for easy display # Add extra fields for easy display
json['information'] = Oj.load(json['history'].first['v1Compatibility']) json['information'] = Oj.load(json['history'].first['v1Compatibility'])
@ -145,43 +72,50 @@ class CraneOp < Sinatra::Base
end end
def fetch_digest(repo, manifest) def fetch_digest(repo, manifest)
h = append_header(hdrs, { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json'}) response = get_head("/v2/#{repo}/manifests/#{manifest}", conf, session, { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json'})
response = HTTParty.head( "#{registry_url}/v2/#{repo}/manifests/#{manifest}", verify: to_bool(registry_ssl_verify), headers: h )
return response.headers["docker-content-digest"] return response.headers["docker-content-digest"]
end end
def image_delete(repo, manifest) def image_delete(repo, manifest)
h = append_header(hdrs, { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json'})
digest = fetch_digest(repo, manifest) digest = fetch_digest(repo, manifest)
response = HTTParty.delete( "#{registry_url}/v2/#{repo}/manifests/#{digest}", verify: to_bool(registry_ssl_verify), headers: h ) return send_delete("/v2/#{repo}/manifests/#{digest}", conf, session, { 'Accept' => 'application/vnd.docker.distribution.manifest.v2+json'})
return response
end end
## Endpoints ## ## Endpoints ##
get '/' do get '/api' do
html :index return "API Version #{conf.version}"
end end
get '/containers.json' do get '/api/containers' do
content_type :json content_type :json
containers(params[:filter]).to_json
containers.to_json
end end
get '/container/*/tags.json' do |container| get '/api/tags/*' do |container|
content_type :json content_type :json
tags = container_tags(container, params[:filter])
tags = container_tags(container)
halt 404 if tags.nil? halt 404 if tags.nil?
tags.to_json tags.to_json
end end
get /container\/(.*\/)(.*.json)/ do |container, tag| post '/api/login' do
content_type :json
params = Oj.load(request.body.read)
session[:username] = params['username']
session[:password] = params['password']
{status: "success"}.to_json
end
get '/logout' do
session.destroy
redirect '/'
end
get /api\/containers\/(.*\/)(.*)/ do |container, tag|
# This is here because we need to handle slashes in container names # This is here because we need to handle slashes in container names
container.chop! container.chop!
tag.gsub!('.json', '')
content_type :json content_type :json
@ -193,28 +127,43 @@ class CraneOp < Sinatra::Base
info.to_json info.to_json
end end
get '/registryinfo' do get '/api/registryinfo' do
content_type :json content_type :json
{ info = {
host: registry_host, host: conf.registry_host,
public_url: registry_public_url, public_url: conf.registry_public_url,
port: registry_port, port: conf.registry_port,
protocol: registry_proto, protocol: conf.registry_protocol,
ssl_verify: to_bool(registry_ssl_verify), ssl_verify: conf.ssl_verify,
delete_allowed: to_bool(delete_allowed), delete_allowed: conf.delete_allowed,
}.to_json }
if session[:username]
info[:username] = session[:username]
end
info.to_json
end end
delete /container\/(.*\/)(.*.json)/ do |container, tag| delete /api\/containers\/(.*\/)(.*)/ do |container, tag|
halt 404 unless to_bool(delete_allowed) halt 404 unless to_bool(delete_allowed)
container.chop! container.chop!
tag.gsub!('.json', '')
response = image_delete( container, tag ) response = image_delete( container, tag )
headers = response.headers headers = response.headers
response.body response.body
end end
# send endpoints that the react app handles
[
'/',
'/container',
'/container/*',
'/login',
].each do |route|
get route do
html :index
end
end
# Error Handlers # Error Handlers
error do error do
File.read(File.join('public', '500.html')) File.read(File.join('public', '500.html'))