构建Heroku克隆 - 以编程方式提供基础设施

147 阅读15分钟

Heroku是一个平台即服务,使开发者能够完全在云中构建、运行和操作应用程序。

Heroku使创建虚拟机来托管应用程序和部署网站等事情变得简单。

Heroku提供的一些功能实际上可以用其他工具轻松创建。

在这篇文章中,你将学习如何创建一个非常简单的网络应用,允许用户通过点击按钮来配置虚拟机和部署静态网站,所有这些都托管在亚马逊网络服务。

你将学习如何用代码配置基础设施,然后你将能够把你学到的东西应用到你自己的应用程序中。

这篇文章是我们刚刚在freeCodeCamp.org YouTube频道上发布的完整课程的补充,该课程将教你如何使用Python以编程方式配置基础设施。

你可以在下面或freeCodeCamp.org YouTube频道上观看该课程(1.5小时的观看时间)。

配置基础设施与平台工程有关。一个平台工程团队通过规划、设计和管理其云平台来为一个组织服务。而这往往可以通过编程来完成。

我在本课程中讲授的工具不仅仅可以用于配置虚拟机和部署网站。它们可以用于平台工程,使云平台的管理更加简单。

自动化API

这门课程的重点是Pulumi的自动化API。Pulumi为freeCodeCamp提供了一笔资金,使本课程成为可能。

Pulumi的开源基础设施即代码SDK使你能够使用许多不同的编程语言,在任何云上创建、部署和管理基础设施。

他们的自动化API使得使用Pulumi引擎以编程方式提供基础设施成为可能。基本上,它使编写一个程序变得简单,可以在各种不同的云平台上自动创建虚拟机、数据库、VPC、静态网站等。

我将向你展示如何使用Flask和Python在后端创建我们的Heroku克隆网络应用。然而,你不需要知道如何使用Flask和Python来跟随。此外,我向你展示的一切也可以用许多其他不同的框架和编程语言来完成,无论你使用什么网络框架,许多步骤都是一样的。

我们的应用程序将在AWS上配置资源,但Pulumi使其能够简单地在大多数主要的云供应商上配置资源,而且不需要对代码进行太多的更新就能使用不同的供应商。

感谢Komal Ali,他创建的代码是我在本课程中的代码的基础。

创建Heroku克隆系统

Pulumi CLI

首先,确保你已经安装了Pulumi CLI。根据你的操作系统,安装的方式是不同的。

如果你有MacsOS和Homebrew,你可以使用命令brew install pulumi

如果你有Windows和Chocolatey,你可以使用命令choco install pulumi

AWS CLI

这个项目也使用了AWS,所以你必须确保你有一个AWS账户,并且设置了CLI并进行了验证。

你可以在这里注册一个免费的AWS账户:https://aws.amazon.com/free/

了解如何为你的操作系统安装AWS CLI:https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html

对于MacOS,你可以使用这些命令:

curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /

对于Windows来说,有一些额外的步骤,你应该只按照这里的指示来做。

接下来,你需要从AWS获得一个访问密钥ID和秘密访问密钥。按照亚马逊的指示来获取这些

现在在命令行中运行以下程序:

aws configure

在提示时输入你的访问密钥ID和秘密访问密钥。你可以保持 "默认区域名称 "和 "默认输出格式 "为无。

设置项目

一个Pulumi项目只是一个包含一些文件的目录。你有可能手工创建一个新的项目。然而,pulumi new 命令可以自动完成这一过程。

pulumi new

如果这是你第一次使用Pulumi,你会被要求输入一个访问代码或登录。要获得一个访问代码,请到app.pulumi.com/account/tok…

pulumi new 命令允许你在许多模板中进行选择。选择 "aws-python "模板。你可以选择其他选项的默认值。

这个命令已经创建了我们需要的所有文件,初始化了一个名为dev (我们项目的一个实例)的新栈。

每个Pulumi程序都被部署到一个堆栈。堆栈是一个孤立的、可独立配置的Pulumi程序的实例。堆栈通常用于表示开发的不同阶段。在这种情况下,我们称它为 "dev"。

现在我们需要为我们的项目安装另外两个依赖项,即

venv/bin/pip install flask requests

创建Flask应用程序

我们现在可以开始为我们的Flask网络应用创建文件了。

Pulumi创建了一个名为"__main__.py "的文件。把这个文件的名字改为 "app.py",因为这是一个Flask应用,Flask会寻找一个有这个名字的文件。

该文件中有一些代码,我们最终会使用与之类似的代码,但现在我们只需将 "app.py "中的代码替换为以下内容,以测试flask是否正常。

from flask import Flask
  
app = Flask(__name__)
  
@app.route('/')
def hello_world():
    return 'Hello World'
  
if __name__ == '__main__':
  
    app.run()

这是最基本的Flask网络应用,我们可以通过在终端运行以下命令来测试它。

env/bin/flask run

然后我们可以在网页浏览器中访问正在运行的应用程序,网址是"http://127.0.0.1:5000/"

如果你进入该网址,它应该显示 "Hello World"。如果是这样,Flask就能正常工作,所以我们可以将 "app.py "文件更新为以下内容。

import os
from flask import Flask, render_template

import pulumi.automation as auto


def ensure_plugins():
    ws = auto.LocalWorkspace()
    ws.install_plugin("aws", "v4.0.0")


def create_app():
    ensure_plugins()
    app = Flask(__name__, instance_relative_config=True)
    app.config.from_mapping(
        SECRET_KEY="secret",
        PROJECT_NAME="herocool",
        PULUMI_ORG=os.environ.get("PULUMI_ORG"),
    )

    @app.route("/", methods=["GET"])
    def index():
        return render_template("index.html")

    from . import sites

    app.register_blueprint(sites.bp)

    from . import virtual_machines

    app.register_blueprint(virtual_machines.bp)

    return app

我们将Pulumi的自动化框架作为一个变量导入,称为auto 。然后在ensure_plugins 函数中,我们可以访问LocalWorkspace 。一个Workspace 是包含单个Pulumi项目的执行环境。工作空间用于管理执行环境,提供各种实用工具,如插件安装、环境配置以及创建、删除和列出堆栈。

因为我们在本教程中部署了AWS资源,所以我们必须在Workspace 内安装AWS提供者插件,这样Pulumi程序在执行过程中就可以得到它。

create_app 函数是Flask在我们启动flask应用时要运行的。我们首先运行ensure_plugins 函数,然后创建Flask应用。app.config.from_mapping() 函数被Flask用来设置应用程序将使用的一些默认配置。这不会是一个生产就绪的应用程序,但如果你曾经部署一个Flask应用程序,请确保改变秘密密钥。其他变量是由pulumi使用的。

文件的其余部分是Flask应用所共有的。我们设置默认路由(@app.route("/", methods=["GET"]) )来渲染模板 "index.html"(我们仍然需要创建这个模板。

最后,该文件导入site和virtual_machines文件,并将其注册为蓝图。

Flask中的蓝图是一种组织一组相关视图和其他代码的方式。与其直接在应用程序中注册视图和其他代码,不如在蓝图中注册它们。然后,当蓝图在工厂函数中可用时,它就被注册到应用程序中。

基本上,我们使用蓝图,这样我们就可以在其他文件中为我们的网络应用定义额外的路由,而我们仍然需要创建这些文件。

创建模板

说到创建其他文件,让我们来创建一些。创建一个名为 "templates "的目录,然后在该目录中创建一个名为 "index.html "的文件。

添加以下代码。

{% extends "base.html" %}

{% block content %}
  <div class="row row-cols-1 row-cols-md-2 g-4">
    <div class="col">
      <div class="card">
        <div class="card-body">
          <h5 class="card-title">Static Websites</h5>
          <p class="card-text">Deploy your own static website in seconds!</p>
          <a href="{{ url_for("sites.list_sites") }}" class="btn btn-primary">Get started</a>
        </div>
      </div>
    </div>
    <div class="col">
      <div class="card">
        <div class="card-body">
          <h5 class="card-title">Virtual Machines</h5>
          <p class="card-text">Set up a virtual machine for development and testing.</p>
          <a href="{{ url_for("virtual_machines.list_vms") }}" class="btn btn-primary">Get started</a>
        </div>
      </div>
    </div>
  </div>
{% endblock %}

我不会对这段代码进行太多详细说明。它是基本的HTML,但大括号内的任何内容都是一个表达式,将被输出到最终的文档中。Flask使用Jinja模板库来渲染模板。你会注意到,它动态地渲染了一个网站和虚拟机的列表。很快我们将创建Python代码,使用Pulumi获得这些列表。

但接下来创建一个名为 "base.html "的文件。你会注意到,"index.html "扩展了 "base.html"。这基本上将是我们所有页面共享的标题。

添加这段代码。

<!DOCTYPE html>
<head>
  <title>Herocool - {% block title %}{% endblock %}</title>
  <link rel="stylesheet" href="{{ url_for("static", filename="bootstrap.min.css") }}">
  <script src="{{ url_for("static", filename="bootstrap.min.js") }}"></script>
</head>
<body>
  <div class="container p-2">
    <header class="d-flex flex-wrap justify-content-center py-3 mb-4 border-bottom">
      <a href="{{ url_for("index") }}" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
        <span class="fs-3">Herocool</span>
      </a>
      {% block nav %}{% endblock %}
    </header>
  </div>
  <section class="container-md px-4">
    <div>
      <header class="row gy-4">
        <span class="fs-4">{% block header %}{% endblock %}</span>
        {% for category, message in get_flashed_messages(with_categories=true) %}
        <div class="alert alert-{{ category }}" role="alert">{{ message }}</div>
        {% endfor %}
      </header>
    </div>
    {% block content %}{% endblock %}
  </section>
</body>

现在,我们将快速创建其余的HTML模板,然后创建Python文件,在我们的应用程序中做真正的重活。

在我们的 "templates "目录中,再创建两个名为 "sites "和 "virtual_machines "的目录。在这两个目录中分别创建同样的三个文件。"index.html", "create.html", 和 "update.html"。

下面是要添加到每个文件的代码。

sites/index.html

{% extends "base.html" %}

{% block nav %}
  <ul class="nav nav-pills">
    <li class="nav-item fs-6"><a href="{{ url_for("sites.create_site") }}" class="nav-link active">Create static site</a></li>
  </ul>
{% endblock %}

{% block header %}
  {% block title %}Site Directory{% endblock %}
{% endblock %}

{% block content %}
  <table class="table">
    <tbody>
      {% if not sites %}
      <div class="container gy-5">
        <div class="row py-4">
          <div class="alert alert-secondary" role="alert">
            <p>No websites are currently deployed. Create one to get started!</p>
            <a href="{{ url_for("sites.create_site") }}" class="btn btn-primary">Create static site</a>
          </div>
        </div>
      </div>
      {%  endif %}
      {% for site in sites %}
        <tr>
          <td class="align-bottom" colspan="4">
            <div class="p-1">
              <a href="{{ site["url"] }}" class="fs-5 align-bottom" target="_blank">{{ site["name"] }}</a>
            </div>
          </td>
          <td>
            <div class="float-end p-1">
              <form action="{{ url_for("sites.delete_site", id=site["name"]) }}" method="post">
                <input class="btn btn-sm btn-danger" type="submit" value="Delete">
              </form>
            </div>
            <div class="float-end p-1">
              <form action="{{ url_for("sites.update_site", id=site["name"]) }}" method="get">
                <input class="btn btn-sm btn-primary" type="submit" value="Edit">
              </form>
            </div>
            <div class="float-end p-1">
              <a href="{{ site["console_url"] }}" class="btn btn-sm btn-outline-primary" target="_blank">View in console</a>
            </div>
          </td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}

sites/create.html

{% extends "base.html" %}

{% block nav %}
  <ul class="nav nav-pills">
    <li class="nav-item fs-6"><a href="{{ url_for("sites.list_sites") }}" class="nav-link">Back to site directory</a></li>
  </ul>
{% endblock %}

{% block header %}
  {% block title %}Create new static site{% endblock %}
{% endblock %}

{% block content %}
<section class="p-2">
  <form method="post">
    <div class="mb-3">
      <label for="site-id" class="form-label">Name</label>
      <input type="text" class="form-control" name="site-id" id="site-id" aria-describedby="nameHelp" required>
      <div id="nameHelp" class="form-text">Choose a unique name as a label for your website</div>
    </div>
    <div class="mb-3">
      <label for="file-url" class="form-label">File URL</label>
      <input type="text" class="form-control" id="file-url" name="file-url">
    </div>
    <div class="mb-3">
      <strong>OR</strong>
    </div>
    <div class="mb-3">
      <label for="site-content" class="form-label">Content</label>
      <textarea class="form-control" name="site-content" id="site-content" rows="5"></textarea>
    </div>
    <button type="submit" class="btn btn-primary">Create</button>
  </form>
</section>
{% endblock %}

sites/update.html

{% extends "base.html" %}

{% block nav %}
  <ul class="nav nav-pills">
    <li class="nav-item fs-6"><a href="{{ url_for("sites.list_sites") }}" class="nav-link">Back to site directory</a></li>
  </ul>
{% endblock %}

{% block header %}
  {% block title %}Update site '{{ name }}'{% endblock %}
{% endblock %}

{% block content %}
  <section class="p-2">
    <form method="post">
      <div class="mb-3">
        <label for="file-url" class="form-label">File URL</label>
        <input type="text" class="form-control" name="file-url" id="file-url">
      </div>
      <div class="mb-3">
        <strong>OR</strong>
      </div>
      <div class="mb-3">
        <label for="site-content" class="form-label">Content</label>
        <textarea class="form-control" name="site-content" id="site-content" rows="5">{{ content }}</textarea>
      </div>
      <button type="submit" class="btn btn-primary">Update</button>
    </form>
  </section>
{% endblock %}

virtual_machines/index.html

{% extends "base.html" %}

{% block nav %}
  <ul class="nav nav-pills">
    <li class="nav-item fs-6"><a href="{{ url_for("virtual_machines.create_vm") }}" class="nav-link active">Create VM</a></li>
  </ul>
{% endblock %}

{% block header %}
  {% block title %}Deployed Virtual Machines{% endblock %}
{% endblock %}

{% block content %}
  <table class="table">
    <tbody>
      {% if not vms %}
      <div class="container gy-5">
        <div class="row py-4">
          <div class="alert alert-secondary" role="alert">
            <p>No virtual machines are currently deployed. Create one to get started!</p>
            <a href="{{ url_for("virtual_machines.create_vm") }}" class="btn btn-primary">Create VM</a>
          </div>
        </div>
      </div>
      {%  endif %}
      {% for vm in vms %}
      <tr>
        <td class="align-bottom" colspan="4">
          <div class="p-1">
            <pre> ssh -i ~/.ssh/id_rsa.pem ec2-user@{{ vm["dns_name"] }} </pre>
          </div>
        </td>
        <td>
          <div class="float-end p-1">
            <form action="{{ url_for("virtual_machines.delete_vm", id=vm["name"]) }}" method="post">
              <input class="btn btn-sm btn-danger" type="submit" value="Delete">
            </form>
          </div>
          <div class="float-end p-1">
            <form action="{{ url_for("virtual_machines.update_vm", id=vm["name"]) }}" method="get">
              <input class="btn btn-sm btn-primary" type="submit" value="Edit">
            </form>
          </div>
          <div class="float-end p-1">
            <a href="{{ vm["console_url"] }}" class="btn btn-sm btn-outline-primary" target="_blank">View in console</a>
          </div>
        </td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}

虚拟机器/创建.html

{% extends "base.html" %}

{% block nav %}
  <ul class="nav nav-pills">
    <li class="nav-item fs-6"><a href="{{ url_for("virtual_machines.list_vms") }}" class="nav-link">Back to Virtual Machines directory</a></li>
  </ul>
{% endblock %}

{% block header %}
  {% block title %}Create new virtual machine{% endblock %}
{% endblock %}

{% block content %}
<section class="p-2">
  <form method="post">
    <div class="mb-3">
      <label for="vm-id" class="form-label">Name</label>
      <input type="text" class="form-control" name="vm-id" id="vm-id" aria-describedby="nameHelp" required>
      <div id="nameHelp" class="form-text">Choose a unique name as a label for your virtual machine</div>
    </div>
    <div class="mb-3">
      <label for="vm-id" class="form-label">Instance Type</label>
      <select name="instance_type" class="form-control" id="instance_type">
      {% for instance_type in instance_types %}
        <option value="{{ instance_type }}" {% if instance_type == curr_instance_type %} selected {% endif %}>
          {{ instance_type }}
        </option>
      {% endfor %}
      </select>
    </div>
    <div class="mb-3">
      <label for="vm-keypair" class="form-label">Public Key</label>
      <textarea class="form-control" name="vm-keypair" id="vm-keypair-content" rows="5" aria-describedby="keypairHelp"></textarea>
      <div id="keypairHelp" class="form-text">The public key to use to connect to the VM </div>
    </div>
    <button type="submit" class="btn btn-primary">Create</button>
  </form>
</section>
{% endblock %}

虚拟机器/更新.html

{% extends "base.html" %}

{% block nav %}
  <ul class="nav nav-pills">
    <li class="nav-item fs-6"><a href="{{ url_for("virtual_machines.list_vms") }}" class="nav-link">Back to Virtual Machines directory</a></li>
  </ul>
{% endblock %}

{% block header %}
  {% block title %}Update Virtual Machine '{{ name }}'{% endblock %}
{% endblock %}

{% block content %}
  <section class="p-2">
    <form method="post">
      <div class="mb-3">
        <label for="vm-id" class="form-label">Instance Type</label>
        <select name="instance_type" class="form-control" id="instance_type">
          {% for instance_type in instance_types %}
            <option value="{{ instance_type }}" {% if instance_type == curr_instance_type %} selected {% endif %}>
              {{ instance_type }}
            </option>
          {% endfor %}
          </select>
      </div>
      <div class="mb-3">
        <label for="vm-keypair" class="form-label">Public Key</label>
        <textarea class="form-control" name="vm-keypair" id="vm-keypair-content" rows="5">{{ public_key }}</textarea>
      </div>
      <button type="submit" class="btn btn-primary">Update</button>
    </form>
  </section>
{% endblock %}

用Python创建功能

现在是使用Python创建Heroku克隆的功能的时候了。app.py "文件从我们仍然需要创建的文件中导入。在与 "app.py "相同的目录下,创建 "sites.py"。

在 "sites.py "中添加以下内容。

import json
import requests
from flask import (
    current_app,
    Blueprint,
    request,
    flash,
    redirect,
    url_for,
    render_template,
)

import pulumi
import pulumi.automation as auto
from pulumi_aws import s3

bp = Blueprint("sites", __name__, url_prefix="/sites")

这些只是我们对Flask和Pulumi需要的基本导入,包括导入自动化框架。

接下来,在该文件中添加以下代码。这个函数根据调用者传入的内容来定义我们的Pulumi s3静态网站。这使我们能够根据用户定义的POST体的值动态地部署网站。

def create_pulumi_program(content: str):
    # Create a bucket and expose a website index document
    site_bucket = s3.Bucket(
        "s3-website-bucket", website=s3.BucketWebsiteArgs(index_document="index.html")
    )
    index_content = content

    # Write our index.html into the site bucket
    s3.BucketObject(
        "index",
        bucket=site_bucket.id,
        content=index_content,
        key="index.html",
        content_type="text/html; charset=utf-8",
    )

    # Set the access policy for the bucket so all objects are readable
    s3.BucketPolicy(
        "bucket-policy",
        bucket=site_bucket.id,
        policy=site_bucket.id.apply(
            lambda id: json.dumps(
                {
                    "Version": "2012-10-17",
                    "Statement": {
                        "Effect": "Allow",
                        "Principal": "*",
                        "Action": ["s3:GetObject"],
                        # Policy refers to bucket explicitly
                        "Resource": [f"arn:aws:s3:::{id}/*"],
                    },
                }
            )
        ),
    )

    # Export the website URL
    pulumi.export("website_url", site_bucket.website_endpoint)
    pulumi.export("website_content", index_content)

你在代码末尾看到的pulumi.export ,目的是为了导出一个命名的堆栈输出。输出的值被附加到程序的堆栈资源中。稍后你将看到我们如何访问这些被导出的数据。

到目前为止,没有任何东西在调用我们刚刚创建的函数。在这一点上,我们将创建URL端点,可以用来调用该函数并创建网站。

首先,我们将为/new URL添加代码,它将创建一个新的网站。

@bp.route("/new", methods=["GET", "POST"])
def create_site():
    """creates new sites"""
    if request.method == "POST":
        stack_name = request.form.get("site-id")
        file_url = request.form.get("file-url")
        if file_url:
            site_content = requests.get(file_url).text
        else:
            site_content = request.form.get("site-content")

        def pulumi_program():
            return create_pulumi_program(str(site_content))

        try:
            # create a new stack, generating our pulumi program on the fly from the POST body
            stack = auto.create_stack(
                stack_name=str(stack_name),
                project_name=current_app.config["PROJECT_NAME"],
                program=pulumi_program,
            )
            stack.set_config("aws:region", auto.ConfigValue("us-east-1"))
            # deploy the stack, tailing the logs to stdout
            stack.up(on_output=print)
            flash(
                f"Successfully created site '{stack_name}'", category="success")
        except auto.StackAlreadyExistsError:
            flash(
                f"Error: Site with name '{stack_name}' already exists, pick a unique name",
                category="danger",
            )

        return redirect(url_for("sites.list_sites"))

    return render_template("sites/create.html")

对于一个GET请求,这个路由渲染了 "create.html "模板。对于一个POST请求,它创建一个新的网站,并重定向到/ 路由的网站列表。

你会注意到,有一个stack 。作为提醒,每个Pulumi程序都被部署到一个堆栈。堆栈是一个孤立的、可独立配置的Pulumi程序的实例。在这段代码中,堆栈的名称是基于用户在表格中输入的ID。还有一个项目名称,以及程序,也就是我们之前讨论的代码块。

一旦堆栈被创建,我们可以对Stack ,包括更新、预览、刷新、销毁、导入和导出等命令。在这段代码中,我们使用stack.up() ,以up栈的日期。并且我们传入一个.callback函数给stack.up() ,用于标准输出。

接下来添加根URL的代码,将列出网站。

@bp.route("/", methods=["GET"])
def list_sites():
    """lists all sites"""
    sites = []
    org_name = current_app.config["PULUMI_ORG"]
    project_name = current_app.config["PROJECT_NAME"]
    try:
        ws = auto.LocalWorkspace(
            project_settings=auto.ProjectSettings(
                name=project_name, runtime="python")
        )
        all_stacks = ws.list_stacks()
        for stack in all_stacks:
            stack = auto.select_stack(
                stack_name=stack.name,
                project_name=project_name,
                # no-op program, just to get outputs
                program=lambda: None,
            )
            outs = stack.outputs()
            if 'website_url' in outs:
                sites.append(
                    {
                        "name": stack.name,
                        "url": f"http://{outs['website_url'].value}",
                        "console_url": f"https://app.pulumi.com/{org_name}/{project_name}/{stack.name}",
                    }
                )
    except Exception as exn:
        flash(str(exn), category="danger")

    return render_template("sites/index.html", sites=sites)

一个Workspace 是包含一个Pulumi项目、一个程序和多个堆栈的执行环境。所以我们首先获得访问ws = auto.LocalWorkspace()

然后我们list_stacks() 。这只是让我们访问堆栈的名称,所以我们必须使用auto.select_stack() ,以获得对堆栈的访问。我们特别想要stack.outputs() 。这将是我们输出到堆栈的内容,包括website_url

然后,我们将每个网站的信息附加到sites 列表中,该列表在前台用于显示网站列表。

现在,添加/update路线的代码。

@bp.route("/<string:id>/update", methods=["GET", "POST"])
def update_site(id: str):
    stack_name = id

    if request.method == "POST":
        file_url = request.form.get("file-url")
        if file_url:
            site_content = requests.get(file_url).text
        else:
            site_content = str(request.form.get("site-content"))

        try:

            def pulumi_program():
                create_pulumi_program(str(site_content))

            stack = auto.select_stack(
                stack_name=stack_name,
                project_name=current_app.config["PROJECT_NAME"],
                program=pulumi_program,
            )
            stack.set_config("aws:region", auto.ConfigValue("us-east-1"))
            # deploy the stack, tailing the logs to stdout
            stack.up(on_output=print)
            flash(f"Site '{stack_name}' successfully updated!",
                  category="success")
        except auto.ConcurrentUpdateError:
            flash(
                f"Error: site '{stack_name}' already has an update in progress",
                category="danger",
            )
        except Exception as exn:
            flash(str(exn), category="danger")
        return redirect(url_for("sites.list_sites"))

    stack = auto.select_stack(
        stack_name=stack_name,
        project_name=current_app.config["PROJECT_NAME"],
        # noop just to get the outputs
        program=lambda: None,
    )
    outs = stack.outputs()
    content_output = outs.get("website_content")
    content = content_output.value if content_output else None
    return render_template("sites/update.html", name=stack_name, content=content)

你会注意到,/update路由与/new路由非常相似。因为它使用相同的stack_name ,所以不会创建一个新的网站。

这个函数中的一个新东西是,代码得到了网站的内容,以返回到前台模板中。

最后,为一个/delete路由添加以下代码。

@bp.route("/<string:id>/delete", methods=["POST"])
def delete_site(id: str):
    stack_name = id
    try:
        stack = auto.select_stack(
            stack_name=stack_name,
            project_name=current_app.config["PROJECT_NAME"],
            # noop program for destroy
            program=lambda: None,
        )
        stack.destroy(on_output=print)
        stack.workspace.remove_stack(stack_name)
        flash(f"Site '{stack_name}' successfully deleted!", category="success")
    except auto.ConcurrentUpdateError:
        flash(
            f"Error: Site '{stack_name}' already has update in progress",
            category="danger",
        )
    except Exception as exn:
        flash(str(exn), category="danger")

    return redirect(url_for("sites.list_sites"))

在这个删除函数中,我们首先获得对堆栈的访问权,然后销毁并删除堆栈。仅仅通过调用stack.destroy() 函数就可以删除AWS上的资源。

现在,在与 "app.py "相同的目录下,创建 "virtual_machines.py"。

在 "virtual_machines.py "中添加以下内容。这一次,我们将一次性地添加它。

from flask import (Blueprint, current_app, request, flash,
                   redirect, url_for, render_template)

import pulumi
import pulumi_aws as aws
import pulumi.automation as auto
import os
from pathlib import Path

bp = Blueprint("virtual_machines", __name__, url_prefix="/vms")
instance_types = ['c5.xlarge', 'p2.xlarge', 'p3.2xlarge']


def create_pulumi_program(keydata: str, instance_type=str):
    # Choose the latest minimal amzn2 Linux AMI.
    # TODO: Make this something the user can choose.
    ami = aws.ec2.get_ami(most_recent=True,
                          owners=["amazon"],
                          filters=[aws.GetAmiFilterArgs(name="name", values=["*amzn2-ami-minimal-hvm*"])])

    group = aws.ec2.SecurityGroup('web-secgrp',
                                  description='Enable SSH access',
                                  ingress=[aws.ec2.SecurityGroupIngressArgs(
                                      protocol='tcp',
                                      from_port=22,
                                      to_port=22,
                                      cidr_blocks=['0.0.0.0/0'],
                                  )])

    public_key = keydata
    if public_key is None or public_key == "":
        home = str(Path.home())
        f = open(os.path.join(home, '.ssh/id_rsa.pub'), 'r')
        public_key = f.read()
        f.close()

    public_key = public_key.strip()

    print(f"Public Key: '{public_key}'\n")

    keypair = aws.ec2.KeyPair("dlami-keypair", public_key=public_key)

    server = aws.ec2.Instance('dlami-server',
                              instance_type=instance_type,
                              vpc_security_group_ids=[group.id],
                              key_name=keypair.id,
                              ami=ami.id)

    pulumi.export('instance_type', server.instance_type)
    pulumi.export('public_key', keypair.public_key)
    pulumi.export('public_ip', server.public_ip)
    pulumi.export('public_dns', server.public_dns)


@bp.route("/new", methods=["GET", "POST"])
def create_vm():
    """creates new VM"""
    if request.method == "POST":
        stack_name = request.form.get("vm-id")
        keydata = request.form.get("vm-keypair")
        instance_type = request.form.get("instance_type")

        def pulumi_program():
            return create_pulumi_program(keydata, instance_type)
        try:
            # create a new stack, generating our pulumi program on the fly from the POST body
            stack = auto.create_stack(
                stack_name=str(stack_name),
                project_name=current_app.config["PROJECT_NAME"],
                program=pulumi_program,
            )
            stack.set_config("aws:region", auto.ConfigValue("us-east-1"))
            # deploy the stack, tailing the logs to stdout
            stack.up(on_output=print)
            flash(
                f"Successfully created VM '{stack_name}'", category="success")
        except auto.StackAlreadyExistsError:
            flash(
                f"Error: VM with name '{stack_name}' already exists, pick a unique name",
                category="danger",
            )
        return redirect(url_for("virtual_machines.list_vms"))

    current_app.logger.info(f"Instance types: {instance_types}")
    return render_template("virtual_machines/create.html", instance_types=instance_types, curr_instance_type=None)


@bp.route("/", methods=["GET"])
def list_vms():
    """lists all vms"""
    vms = []
    org_name = current_app.config["PULUMI_ORG"]
    project_name = current_app.config["PROJECT_NAME"]
    try:
        ws = auto.LocalWorkspace(
            project_settings=auto.ProjectSettings(
                name=project_name, runtime="python")
        )
        all_stacks = ws.list_stacks()
        for stack in all_stacks:
            stack = auto.select_stack(
                stack_name=stack.name,
                project_name=project_name,
                # no-op program, just to get outputs
                program=lambda: None,
            )
            outs = stack.outputs()
            if 'public_dns' in outs:
                vms.append(
                    {
                        "name": stack.name,
                        "dns_name": f"{outs['public_dns'].value}",
                        "console_url": f"https://app.pulumi.com/{org_name}/{project_name}/{stack.name}",
                    }
                )
    except Exception as exn:
        flash(str(exn), category="danger")

    current_app.logger.info(f"VMS: {vms}")
    return render_template("virtual_machines/index.html", vms=vms)


@bp.route("/<string:id>/update", methods=["GET", "POST"])
def update_vm(id: str):
    stack_name = id
    if request.method == "POST":
        current_app.logger.info(
            f"Updating VM: {stack_name}, form data: {request.form}")
        keydata = request.form.get("vm-keypair")
        current_app.logger.info(f"updating keydata: {keydata}")
        instance_type = request.form.get("instance_type")

        def pulumi_program():
            return create_pulumi_program(keydata, instance_type)
        try:
            stack = auto.select_stack(
                stack_name=stack_name,
                project_name=current_app.config["PROJECT_NAME"],
                program=pulumi_program,
            )
            stack.set_config("aws:region", auto.ConfigValue("us-east-1"))
            # deploy the stack, tailing the logs to stdout
            stack.up(on_output=print)
            flash(f"VM '{stack_name}' successfully updated!",
                  category="success")
        except auto.ConcurrentUpdateError:
            flash(
                f"Error: VM '{stack_name}' already has an update in progress",
                category="danger",
            )
        except Exception as exn:
            flash(str(exn), category="danger")
        return redirect(url_for("virtual_machines.list_vms"))

    stack = auto.select_stack(
        stack_name=stack_name,
        project_name=current_app.config["PROJECT_NAME"],
        # noop just to get the outputs
        program=lambda: None,
    )
    outs = stack.outputs()
    public_key = outs.get("public_key")
    pk = public_key.value if public_key else None
    instance_type = outs.get("instance_type")
    return render_template("virtual_machines/update.html", name=stack_name, public_key=pk, instance_types=instance_types, curr_instance_type=instance_type.value)


@bp.route("/<string:id>/delete", methods=["POST"])
def delete_vm(id: str):
    stack_name = id
    try:
        stack = auto.select_stack(
            stack_name=stack_name,
            project_name=current_app.config["PROJECT_NAME"],
            # noop program for destroy
            program=lambda: None,
        )
        stack.destroy(on_output=print)
        stack.workspace.remove_stack(stack_name)
        flash(f"VM '{stack_name}' successfully deleted!", category="success")
    except auto.ConcurrentUpdateError:
        flash(
            f"Error: VM '{stack_name}' already has update in progress",
            category="danger",
        )
    except Exception as exn:
        flash(str(exn), category="danger")

    return redirect(url_for("virtual_machines.list_vms"))

这个文件和 "sites.py "文件之间有很多相似之处。一个虚拟机还有相当多的设置必须设置,Pulumi能够创建我们想要的确切类型的虚拟机。

我们可以让用户在网络界面上定制一切,但我们只让用户选择实例类型。

一个虚拟机需要的东西是一个公钥/私钥对。在AWS上创建一个虚拟机时,你必须给出一个公钥。然后,为了访问该虚拟机,你必须拥有私钥。

你可以在你的终端中创建密钥。运行以下命令。

ssh-keygen -m PEM

以后在测试应用程序时,你必须打开公钥文件,这样你就可以复制密钥并将其粘贴到网站上。

现在用你刚刚创建的文件切换目录。无论你是在MacOS还是在Windows上,这个目录都应该是一样的。

在你的终端上输入。

cd /Users/[username]/.ssh

AWS需要该文件是.pem格式的,这就是为什么我们在上面用 "PEM "创建它。现在让我们重命名该文件,使其具有正确的扩展名。你需要将名为id_rsa 的文件的名称改为id_rsa.pem

在macOS上,你可以用这个命令重命名。

mv id_rsa id_rsa.pem

在Windows上,使用。

rename id_rsa id_rsa.pem

当运行Flask应用程序时,你可能需要输入你刚刚创建的公钥。你可以在任何文本编辑器中打开id_rsa.pub ,以便复制文本。如果你有vim,你可以用这个命令来打开文件。

vim /Users/beau/.ssh/id_rsa.pub

测试应用程序

现在是时候测试一下这个应用程序了。在终端上,运行这个命令。

FLASK_RUN_PORT=1337 FLASK_ENV=development FLASK_APP=__init__ PULUMI_ORG=[your-org-name] venv/bin/flask run

现在你可以试用该应用程序并创建网站和虚拟机。确保在创建后删除它们,这样AWS就不会继续向你收取资源费用。

总结

你现在应该知道足够的知识,开始使用Pulumi的自动化API在你自己的应用程序中配置基础设施。如果你想逐步了解如何做本文中的事情,请查看视频教程。