Start adding acs workflows talk.
This commit is contained in:
@ -13,4 +13,4 @@ spec:
|
||||
status: "True"
|
||||
clusterSelector:
|
||||
matchLabels:
|
||||
name: local-cluster
|
||||
name: dev-a
|
||||
|
||||
148
2023-07-31-acs-workflows/README.org
Normal file
148
2023-07-31-acs-workflows/README.org
Normal file
@ -0,0 +1,148 @@
|
||||
#+TITLE: RHACS Workflows & Integration
|
||||
#+AUTHOR: James Blair
|
||||
#+DATE: <2023-07-29 Sat 23:15>
|
||||
|
||||
|
||||
This is a short demo I gave on [[https://www.redhat.com/en/technologies/cloud-computing/openshift/advanced-cluster-security-kubernetes][Red Hat Advanced Cluster Security]].
|
||||
|
||||
|
||||
|
||||
* Pre-requisites
|
||||
|
||||
This demo setup process assumes you already have an OpenShift 4.12+ cluster running, and are logged into the ~oc~ cli locally with cluster administration privileges.
|
||||
|
||||
For this demo I have an OpenShift ~4.12.12~ cluster running on AWS provisioned through the [[https://demo.redhat.com/catalog?item=babylon-catalog-prod/sandboxes-gpte.elt-ocp4-hands-on-acs.prod&utm_source=webapp&utm_medium=share-link][Red Hat Demo system]].
|
||||
|
||||
#+NAME: Check oc status
|
||||
#+begin_src bash :results silent
|
||||
oc version | grep Server
|
||||
oc status
|
||||
#+end_src
|
||||
|
||||
|
||||
* Developer workflow integration
|
||||
|
||||
A key element of any cloud native security platform is how it can be incorporated into software development workflows to enable security teams to gain visibility of emerging security issues and also empower developers to understand the security posture of what they are building.
|
||||
|
||||
For this demonstration we will be using [[https://developers.redhat.com/products/openshift-dev-spaces/overview][OpenShift Dev Spaces]] as a cloud based development environment, and [[https://marketplace.visualstudio.com/items?itemName=redhat.vscode-tekton-pipelines][OpenShift Pipelines]] for a continuous integration environment.
|
||||
|
||||
|
||||
** Install dev spaces operator
|
||||
|
||||
The first step to prepare the demo is to install the dev spaces operator so our cluster will be able to create cloud based development environments. We can install the operator programmatically by creating a ~subscription~ resource:
|
||||
|
||||
#+begin_src bash :results silent
|
||||
cat << EOF | oc apply -f -
|
||||
apiVersion: operators.coreos.com/v1alpha1
|
||||
kind: Subscription
|
||||
metadata:
|
||||
name: devspaces
|
||||
namespace: openshift-operators
|
||||
spec:
|
||||
channel: stable
|
||||
installPlanApproval: Automatic
|
||||
name: devspaces
|
||||
source: redhat-operators
|
||||
sourceNamespace: openshift-marketplace
|
||||
EOF
|
||||
#+end_src
|
||||
|
||||
|
||||
** Create devspaces controller
|
||||
|
||||
Once the operator is installed we can create a devspaces controller instance, this will be what is actually responsible for instantiating new individual developer workspaces.
|
||||
|
||||
Once again we can do this programmatically by creating a ~checluster~ resource:
|
||||
|
||||
#+begin_src bash :results silent
|
||||
cat << EOF | oc apply -f -
|
||||
apiVersion: org.eclipse.che/v2
|
||||
kind: CheCluster
|
||||
metadata:
|
||||
name: devspaces
|
||||
namespace: openshift-operators
|
||||
spec:
|
||||
components:
|
||||
cheServer:
|
||||
debug: false
|
||||
logLevel: INFO
|
||||
dashboard: {}
|
||||
database:
|
||||
externalDb: false
|
||||
devWorkspace: {}
|
||||
devfileRegistry: {}
|
||||
imagePuller:
|
||||
enable: false
|
||||
spec: {}
|
||||
metrics:
|
||||
enable: true
|
||||
pluginRegistry: {}
|
||||
containerRegistry: {}
|
||||
devEnvironments:
|
||||
containerBuildConfiguration:
|
||||
openShiftSecurityContextConstraint: container-build
|
||||
defaultNamespace:
|
||||
autoProvision: true
|
||||
template: <username>-devspaces
|
||||
maxNumberOfWorkspacesPerUser: -1
|
||||
secondsOfInactivityBeforeIdling: 36000
|
||||
secondsOfRunBeforeIdling: -1
|
||||
startTimeoutSeconds: 300
|
||||
storage:
|
||||
pvcStrategy: per-user
|
||||
gitServices: {}
|
||||
networking:
|
||||
auth:
|
||||
gateway:
|
||||
configLabels:
|
||||
app: che
|
||||
component: che-gateway-config
|
||||
EOF
|
||||
#+end_src
|
||||
|
||||
|
||||
** Create individual dev space
|
||||
|
||||
Once the dev workspace operator and controller are ready we can create our individual developer workspace.
|
||||
|
||||
#+begin_src bash :results silent
|
||||
cat << EOF | oc apply -f -
|
||||
kind: DevWorkspace
|
||||
apiVersion: workspace.devfile.io/v1alpha2
|
||||
metadata:
|
||||
name: vscode
|
||||
namespace: opentlc-mgr-devspaces
|
||||
spec:
|
||||
started: true
|
||||
template:
|
||||
projects:
|
||||
- name: talks
|
||||
git:
|
||||
remotes:
|
||||
origin: "https://github.com/jmhbnz/talks.git"
|
||||
components:
|
||||
- name: dev
|
||||
container:
|
||||
image: quay.io/devfile/universal-developer-image:latest
|
||||
commands:
|
||||
- id: install-roxctl
|
||||
exec:
|
||||
component: dev
|
||||
commandLine: curl -O https://mirror.openshift.com/pub/rhacs/assets/4.1.2/bin/Linux/roxctl && chmod +x roxctl
|
||||
workingDir: ${PROJECT_SOURCE}
|
||||
contributions:
|
||||
- name: che-code
|
||||
uri: https://eclipse-che.github.io/che-plugin-registry/main/v3/plugins/che-incubator/che-code/latest/devfile.yaml
|
||||
components:
|
||||
- name: che-code-runtime-description
|
||||
container:
|
||||
env:
|
||||
- name: CODE_HOST
|
||||
value: 0.0.0.0
|
||||
EOF
|
||||
#+end_src
|
||||
|
||||
|
||||
** Deploy sample application
|
||||
|
||||
In order to showcase incorporating ~roxctl~ into developer workflows we need a sample application to tinker with.
|
||||
20
2023-07-31-acs-workflows/guestbook/Dockerfile
Normal file
20
2023-07-31-acs-workflows/guestbook/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
FROM golang as builder
|
||||
RUN go install github.com/codegangsta/negroni@v1.0.0
|
||||
RUN go install github.com/gorilla/mux@v1.8.0
|
||||
RUN go install github.com/xyproto/simpleredis@2.6.5
|
||||
|
||||
COPY main.go .
|
||||
RUN go build main.go
|
||||
|
||||
FROM busybox:ubuntu-14.04
|
||||
|
||||
COPY --from=builder /go//main /app/guestbook
|
||||
|
||||
ADD public/index.html /app/public/index.html
|
||||
ADD public/script.js /app/public/script.js
|
||||
ADD public/style.css /app/public/style.css
|
||||
ADD public/jquery.min.js /app/public/jquery.min.js
|
||||
|
||||
WORKDIR /app
|
||||
CMD ["./guestbook"]
|
||||
EXPOSE 3000
|
||||
25
2023-07-31-acs-workflows/guestbook/Makefile
Normal file
25
2023-07-31-acs-workflows/guestbook/Makefile
Normal file
@ -0,0 +1,25 @@
|
||||
# Build the guestbook example
|
||||
# Usage:
|
||||
# [VERSION=v2] [REGISTRY="docker.io/ibmcom"] make build
|
||||
|
||||
VERSION?=v2
|
||||
REGISTRY?=docker.io/ibmcom
|
||||
|
||||
all: build
|
||||
|
||||
release: clean build push clean
|
||||
|
||||
# Builds a docker image that builds the app and packages it into a
|
||||
# minimal docker image
|
||||
build:
|
||||
docker build --pull -t "${REGISTRY}/guestbook:${VERSION}" .
|
||||
|
||||
# push the image to an registry
|
||||
push: build
|
||||
docker push ${REGISTRY}/guestbook:${VERSION}
|
||||
|
||||
# remove previous images
|
||||
clean:
|
||||
docker rmi -f "${REGISTRY}/guestbook:${VERSION}" || true
|
||||
|
||||
.PHONY: release clean build push all
|
||||
293
2023-07-31-acs-workflows/guestbook/main.go
Normal file
293
2023-07-31-acs-workflows/guestbook/main.go
Normal file
@ -0,0 +1,293 @@
|
||||
/*
|
||||
Copyright 2014 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/codegangsta/negroni"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/xyproto/simpleredis"
|
||||
)
|
||||
|
||||
var (
|
||||
// For when Redis is used
|
||||
masterPool *simpleredis.ConnectionPool
|
||||
slavePool *simpleredis.ConnectionPool
|
||||
|
||||
// For when Redis is not used, we just keep it in memory
|
||||
lists map[string][]string = map[string][]string{}
|
||||
|
||||
// For Healthz
|
||||
startTime time.Time
|
||||
delay float64 = 10 + 5*rand.Float64()
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
InputText string `json:"input_text"`
|
||||
}
|
||||
|
||||
type Tone struct {
|
||||
ToneName string `json:"tone_name"`
|
||||
}
|
||||
|
||||
func GetList(key string) ([]string, error) {
|
||||
// Using Redis
|
||||
if slavePool != nil {
|
||||
list := simpleredis.NewList(slavePool, key)
|
||||
if result, err := list.GetAll(); err == nil {
|
||||
return result, err
|
||||
}
|
||||
// if we can't talk to the slave then assume its not running yet
|
||||
// so just try to use the master instead
|
||||
}
|
||||
|
||||
// if the slave doesn't exist, read from the master
|
||||
if masterPool != nil {
|
||||
list := simpleredis.NewList(masterPool, key)
|
||||
return list.GetAll()
|
||||
}
|
||||
|
||||
// if neither exist, we're probably in "in-memory" mode
|
||||
return lists[key], nil
|
||||
}
|
||||
|
||||
func AppendToList(item string, key string) ([]string, error) {
|
||||
var err error
|
||||
items := []string{}
|
||||
|
||||
// Using Redis
|
||||
if masterPool != nil {
|
||||
list := simpleredis.NewList(masterPool, key)
|
||||
list.Add(item)
|
||||
items, err = list.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
items = lists[key]
|
||||
items = append(items, item)
|
||||
lists[key] = items
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func ListRangeHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
var data []byte
|
||||
|
||||
items, err := GetList(mux.Vars(req)["key"])
|
||||
if err != nil {
|
||||
data = []byte("Error getting list: " + err.Error() + "\n")
|
||||
} else {
|
||||
if data, err = json.MarshalIndent(items, "", ""); err != nil {
|
||||
data = []byte("Error marhsalling list: " + err.Error() + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
rw.Write(data)
|
||||
}
|
||||
|
||||
func ListPushHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
var data []byte
|
||||
|
||||
key := mux.Vars(req)["key"]
|
||||
value := mux.Vars(req)["value"]
|
||||
|
||||
// propogate headers to analyzer service
|
||||
headers := getForwardHeaders(req.Header)
|
||||
|
||||
// Add in the "tone" analyzer results
|
||||
value += " : " + getPrimaryTone(value, headers)
|
||||
|
||||
items, err := AppendToList(value, key)
|
||||
|
||||
if err != nil {
|
||||
data = []byte("Error adding to list: " + err.Error() + "\n")
|
||||
} else {
|
||||
if data, err = json.MarshalIndent(items, "", ""); err != nil {
|
||||
data = []byte("Error marshalling list: " + err.Error() + "\n")
|
||||
}
|
||||
|
||||
}
|
||||
rw.Write(data)
|
||||
}
|
||||
|
||||
func InfoHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
info := ""
|
||||
|
||||
// Using Redis
|
||||
if masterPool != nil {
|
||||
i, err := masterPool.Get(0).Do("INFO")
|
||||
if err != nil {
|
||||
info = "Error getting DB info: " + err.Error()
|
||||
} else {
|
||||
info = string(i.([]byte))
|
||||
}
|
||||
} else {
|
||||
info = "In-memory datastore (not redis)"
|
||||
}
|
||||
rw.Write([]byte(info + "\n"))
|
||||
}
|
||||
|
||||
func EnvHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
environment := make(map[string]string)
|
||||
for _, item := range os.Environ() {
|
||||
splits := strings.Split(item, "=")
|
||||
key := splits[0]
|
||||
val := strings.Join(splits[1:], "=")
|
||||
environment[key] = val
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(environment, "", "")
|
||||
if err != nil {
|
||||
data = []byte("Error marshalling env vars: " + err.Error())
|
||||
}
|
||||
|
||||
rw.Write(data)
|
||||
}
|
||||
|
||||
func HelloHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.Write([]byte("Hello from guestbook. " +
|
||||
"Your app is up! (Hostname: " +
|
||||
os.Getenv("HOSTNAME") +
|
||||
")\n"))
|
||||
}
|
||||
|
||||
func HealthzHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if time.Now().Sub(startTime).Seconds() > delay {
|
||||
http.Error(rw, "Timeout, Health check error!", http.StatusForbidden)
|
||||
} else {
|
||||
rw.Write([]byte("OK!"))
|
||||
}
|
||||
}
|
||||
|
||||
// Note: This function will not work until we hook-up the Tone Analyzer service
|
||||
func getPrimaryTone(value string, headers http.Header) (tone string) {
|
||||
u := Input{InputText: value}
|
||||
b := new(bytes.Buffer)
|
||||
json.NewEncoder(b).Encode(u)
|
||||
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", "http://analyzer:80/tone", b)
|
||||
if err != nil {
|
||||
return "Error talking to tone analyzer service: " + err.Error()
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
// add headers
|
||||
for k := range headers {
|
||||
req.Header.Add(k, headers.Get(k))
|
||||
}
|
||||
// print out headers for debug
|
||||
// fmt.Printf("getPrimaryTone headers %v", req.Header)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "Error detecting tone: " + err.Error()
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body := []Tone{}
|
||||
json.NewDecoder(res.Body).Decode(&body)
|
||||
if len(body) > 0 {
|
||||
// 7 tones: anger, fear, joy, sadness, analytical, confident, and tentative
|
||||
if body[0].ToneName == "Joy" {
|
||||
return body[0].ToneName + " (✿◠‿◠)"
|
||||
} else if body[0].ToneName == "Anger" {
|
||||
return body[0].ToneName + " (ಠ_ಠ)"
|
||||
} else if body[0].ToneName == "Fear" {
|
||||
return body[0].ToneName + " (ง’̀-‘́)ง"
|
||||
} else if body[0].ToneName == "Sadness" {
|
||||
return body[0].ToneName + " (︶︿︶)"
|
||||
} else if body[0].ToneName == "Analytical" {
|
||||
return body[0].ToneName + " ( °□° )"
|
||||
} else if body[0].ToneName == "Confident" {
|
||||
return body[0].ToneName + " (▀̿Ĺ̯▀̿ ̿)"
|
||||
} else if body[0].ToneName == "Tentative" {
|
||||
return body[0].ToneName + " (•_•)"
|
||||
}
|
||||
return body[0].ToneName
|
||||
}
|
||||
|
||||
return "No Tone Detected"
|
||||
}
|
||||
|
||||
// return the needed header for distributed tracing
|
||||
func getForwardHeaders(h http.Header) (headers http.Header) {
|
||||
incomingHeaders := []string{
|
||||
"x-request-id",
|
||||
"x-b3-traceid",
|
||||
"x-b3-spanid",
|
||||
"x-b3-parentspanid",
|
||||
"x-b3-sampled",
|
||||
"x-b3-flags",
|
||||
"x-ot-span-context"}
|
||||
|
||||
header := make(http.Header, len(incomingHeaders))
|
||||
for _, element := range incomingHeaders {
|
||||
val := h.Get(element)
|
||||
if val != "" {
|
||||
header.Set(element, val)
|
||||
}
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
// Support multiple URL schemes for different use cases
|
||||
func findRedisURL() string {
|
||||
host := os.Getenv("REDIS_MASTER_SERVICE_HOST")
|
||||
port := os.Getenv("REDIS_MASTER_SERVICE_PORT")
|
||||
password := os.Getenv("REDIS_MASTER_SERVICE_PASSWORD")
|
||||
master_port := os.Getenv("REDIS_MASTER_PORT")
|
||||
|
||||
if host != "" && port != "" && password != "" {
|
||||
return password + "@" + host + ":" + port
|
||||
} else if master_port != "" {
|
||||
return "redis-master:6379"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
// When using Redis, setup our DB connections
|
||||
url := findRedisURL()
|
||||
if url != "" {
|
||||
masterPool = simpleredis.NewConnectionPoolHost(url)
|
||||
defer masterPool.Close()
|
||||
slavePool = simpleredis.NewConnectionPoolHost("redis-slave:6379")
|
||||
defer slavePool.Close()
|
||||
}
|
||||
|
||||
startTime = time.Now()
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.Path("/lrange/{key}").Methods("GET").HandlerFunc(ListRangeHandler)
|
||||
r.Path("/rpush/{key}/{value}").Methods("GET").HandlerFunc(ListPushHandler)
|
||||
r.Path("/info").Methods("GET").HandlerFunc(InfoHandler)
|
||||
r.Path("/env").Methods("GET").HandlerFunc(EnvHandler)
|
||||
r.Path("/hello").Methods("GET").HandlerFunc(HelloHandler)
|
||||
r.Path("/healthz").Methods("GET").HandlerFunc(HealthzHandler)
|
||||
|
||||
n := negroni.Classic()
|
||||
n.UseHandler(r)
|
||||
n.Run(":3000")
|
||||
}
|
||||
36
2023-07-31-acs-workflows/guestbook/public/index.html
Normal file
36
2023-07-31-acs-workflows/guestbook/public/index.html
Normal file
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width" name="viewport">
|
||||
<link href="style.css" rel="stylesheet">
|
||||
<title>Guestbook - v2</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<h1>Guestbook - v2</h1>
|
||||
</div>
|
||||
|
||||
<div id="guestbook-entries">
|
||||
<link href="https://afeld.github.io/emoji-css/emoji.css" rel="stylesheet">
|
||||
<p>Waiting for database connection... <i class='em em-boat'></i></p>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form id="guestbook-form">
|
||||
<input autocomplete="off" id="guestbook-entry-content" type="text">
|
||||
<a href="#" id="guestbook-submit">Submit</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p><h2 id="guestbook-host-address"></h2></p>
|
||||
<p><a href="env">/env</a>
|
||||
<a href="info">/info</a></p>
|
||||
</div>
|
||||
<script src="jquery.min.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4
2023-07-31-acs-workflows/guestbook/public/jquery.min.js
vendored
Normal file
4
2023-07-31-acs-workflows/guestbook/public/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
38
2023-07-31-acs-workflows/guestbook/public/script.js
Normal file
38
2023-07-31-acs-workflows/guestbook/public/script.js
Normal file
@ -0,0 +1,38 @@
|
||||
$(document).ready(function() {
|
||||
var headerTitleElement = $("#header h1");
|
||||
var entriesElement = $("#guestbook-entries");
|
||||
var formElement = $("#guestbook-form");
|
||||
var submitElement = $("#guestbook-submit");
|
||||
var entryContentElement = $("#guestbook-entry-content");
|
||||
var hostAddressElement = $("#guestbook-host-address");
|
||||
|
||||
var appendGuestbookEntries = function(data) {
|
||||
entriesElement.empty();
|
||||
$.each(data, function(key, val) {
|
||||
entriesElement.append("<p>" + val + "</p>");
|
||||
});
|
||||
}
|
||||
|
||||
var handleSubmission = function(e) {
|
||||
e.preventDefault();
|
||||
var entryValue = entryContentElement.val()
|
||||
if (entryValue.length > 0) {
|
||||
entriesElement.append("<p>...</p>");
|
||||
$.getJSON("rpush/guestbook/" + entryValue, appendGuestbookEntries);
|
||||
entryContentElement.val("")
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
submitElement.click(handleSubmission);
|
||||
formElement.submit(handleSubmission);
|
||||
hostAddressElement.append(document.URL);
|
||||
|
||||
// Poll every second.
|
||||
(function fetchGuestbook() {
|
||||
$.getJSON("lrange/guestbook").done(appendGuestbookEntries).always(
|
||||
function() {
|
||||
setTimeout(fetchGuestbook, 1000);
|
||||
});
|
||||
})();
|
||||
});
|
||||
61
2023-07-31-acs-workflows/guestbook/public/style.css
Normal file
61
2023-07-31-acs-workflows/guestbook/public/style.css
Normal file
@ -0,0 +1,61 @@
|
||||
body, input {
|
||||
color: #123;
|
||||
font-family: "Gill Sans", sans-serif;
|
||||
}
|
||||
|
||||
div {
|
||||
overflow: hidden;
|
||||
padding: 1em 0;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1, h2, p, input, a {
|
||||
font-weight: 300;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #18d;
|
||||
font-size: 3.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0 auto;
|
||||
max-width: 50em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 0;
|
||||
border-radius: 1000px;
|
||||
box-shadow: inset 0 0 0 2px #18d;
|
||||
display: inline;
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
outline: none;
|
||||
padding: .5em 5%;
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
form a {
|
||||
background: #18d;
|
||||
border: 0;
|
||||
border-radius: 1000px;
|
||||
color: #FFF;
|
||||
font-size: 1.25em;
|
||||
font-weight: 400;
|
||||
padding: .75em 2em;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
Reference in New Issue
Block a user