Pipeline on Openshift4(Tekton)

OpenShift Pipeline 是一种云原生,持续集成和交付(CI/CD)解决方案,使用Tekton构建pipeline。

实现了以模块化方式从源码到应用运行态的自动化流程, 源码->制品->容器镜像->应用发布。并可自定义穿插其他模块,如代码扫描、镜像安全、消息推送等。

1. 模块介绍

OpenShift Pipeline 通过自定义资源对象(CRD)以模块化的方式构建pipeline。
主要使用的 CRD 有 task, pipeline, pipelinerun, taskrun,trigger

tekton.png

Task:
task 是在 Pipeline 中可配置的最小单元。作为 pipeline 的一部分也可独立运行。每个还可定义多个 step,顺序执行。 比如 buildah 这个 task 就有 build,push, digest-to-results 三个 step。
task内容主要由 image 和运行脚本组成。spec.params.xx 的值由 pipeline 传入
task 通常会设计为可重复调用。
示例

apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: update-deployment
spec:
  params:
    - name: deployment
      description: The name of the deployment patch the image
      type: string
    - name: IMAGE
      description: Location of image to be patched with
      type: string
  steps:
    - name: patch
      image: image-registry.openshift-image-registry.svc:5000/openshift/cli:latest
      command: ["/bin/bash", "-c"]
      args:
        - |-
          oc patch deployment $(inputs.params.deployment) --patch='{"spec":{"template":{"spec":{
            "containers":[{
              "name": "$(inputs.params.deployment)",
              "image":"$(inputs.params.IMAGE)"
            }]
          }}}}'

piepeline:
Pipeline 由一系列 task 组成的通用工作流模板,并会定义 task 的执行顺序。

PipelineRun:
PipelineRun 是一个 Pipeline 的运行实例。PipelineRun 启动 Pipeline,并为 Pipeline 中执行的每个任务管理一个 TaskRun 的创建。

TaskRun:
PipelineRun 由 Pipeline 中每个任务的 PipelineRun 自动创建。它是在 Pipeline 中运行任务实例的结果。如果某个任务在 Pipeline 之外运行,它也可以被手工创建。

Workspace:
Workspace 是一个存储卷,任务(Task)在运行时需要它来接收输入或提供输出。Task 或 Pipeline 会声明 Workspace,一个TaskRun 或 PipelineRun 则会提供存储卷的实际位置,存储卷被挂载到声明的 Workspace 上。这使得任务具有灵活性、可重复使用,并允许在多个任务间共享工作区。

多个 pipelinerun 共享存储的时候需要注意,有的 task 初始化时候会清除之前的数据,多个 pipelinerun 同时运行并共用存储可能存在数据被清除的情况。

Trigger:
Trigger(触发器)捕获外部事件,如 Git 拉取请求,并处理事件有效负载以获取关键信息。

2. 安装 Red Hat OpenShift Pipelines Operator

如果离线环境,需要先执行离线部署operatorhub
https://github.com/cai11745/ocp4-userguide 可参照《离线部署operatorhub并批量导入》

  1. 在控制台的 Administrator 视角中,Operators → OperatorHub。
  2. 搜索 Red Hat OpenShift Pipelines Operator。点 Red Hat OpenShift Pipelines Operator 。
  3. 在 Install Operator 页面中:
    Installation Mode 选择 All namespaces on the cluster (default)。选择该项会将 Operator 安装至默认openshift-operators 命名空间,这将启用 Operator 以进行监视并在集群中的所有命名空间中可用。
    Approval Strategy(批准策略)选择 Automatic。这样可确保以后对 Operator 的升级由 Operator Lifecycle Manager (OLM) 自动进行。

Update Channel:Stable 频道启用 Red Hat OpenShift Pipelines Operator 最新稳定版本的安装。preview 频道启用 Red Hat OpenShift Pipelines Operator 的最新预览版本,该版本可能包含 Stable 频道中还未提供的功能。

点击 Install。会看到 Installed Operators 页面中列出的 Operator。

检查 Status 变成 Succeeded 表示 Red Hat OpenShift Pipelines Operator 已安装成功。

查看相关api
[root@bastion ~]# oc api-resources --api-group=tekton.dev
NAME                SHORTNAMES   APIVERSION            NAMESPACED   KIND
clustertasks                     tekton.dev/v1beta1    false        ClusterTask
conditions                       tekton.dev/v1alpha1   true         Condition
pipelineresources                tekton.dev/v1alpha1   true         PipelineResource
pipelineruns        pr,prs       tekton.dev/v1beta1    true         PipelineRun
pipelines                        tekton.dev/v1beta1    true         Pipeline
runs                             tekton.dev/v1alpha1   true         Run
taskruns            tr,trs       tekton.dev/v1beta1    true         TaskRun
tasks                            tekton.dev/v1beta1    true         Task

相关pod  
[root@bastion ~]# oc get pod -n openshift-pipelines
NAME                                             READY   STATUS    RESTARTS   AGE
tekton-operator-proxy-webhook-5c86d47c54-kpcvx   1/1     Running   0          6h42m
tekton-pipelines-controller-78f7f7449d-wrcvb     1/1     Running   0          6h42m
tekton-pipelines-webhook-7885bc985b-54pc5        1/1     Running   0          7h10m
tekton-triggers-controller-76c8d6bd-kwgrk        1/1     Running   0          7h10m
tekton-triggers-webhook-6d6cfb6568-gl779         1/1     Running   0          7h10m

3. 场景示例

使用 Red Hat OpenShift Pipelines,创建一个自定义的 CI/CD 解决方案来构建、测试和部署应用程序。

主要以下流程:

  • 创建自定义task,或使用现有的可重复使用的task。
  • 为应用程序创建并定义pipeline。
  • 使用持久化存储添加到pipeline的workspace,以保存中间数据,如代码、制品。
  • 创建一个 PipelineRun 对象来实例化并调用pipeline。
  • 添加tigger以捕获源仓库中的事件。(本文不包含,下一篇做)

使用redhat 官网提供的示例, pipelines-tutorial 来演示。这个示例使用一个简单的应用程序,它由以下部分组成:

一个前端界面 vote-ui,它的源代码在 ui-repo Git
一个后端接口 vote-api,它的源代码在 api-repo Git
apply-manifests 和 update-deployment 任务在 pipelines-tutorial Git

3.1 创建新的project

Pipelines Operator 会自动添加并配置一个名为 pipeline 的 ServiceAccount,它有足够的权限来构建和推送镜像。这个 ServiceAccount 由 PipelineRun 使用。

oc new-project pipelines-tutorial
oc get serviceaccount pipeline

3.2 创建 task

安装 tekton client 命令 tkn

# 下载地址
https://mirror.openshift.com/pub/openshift-v4/clients/pipeline/

wget https://mirror.openshift.com/pub/openshift-v4/clients/pipeline/0.13.1/tkn-linux-amd64-0.13.1.tar.gz
chmod +x tkn 
mv tkn /usr/local/bin/
tkn version
# Client version: 0.13.1
# Pipeline version: v0.19.0
# Triggers version: v0.10.2

从 pipelines-tutorial git库安装 apply-manifests 和 update-deployment 任务资源,其中包含可为管道重复使用的任务列表:

oc create -f https://raw.githubusercontent.com/openshift/pipelines-tutorial/pipelines-1.3/01_pipeline/01_apply_manifest_task.yaml
oc create -f https://raw.githubusercontent.com/openshift/pipelines-tutorial/pipelines-1.3/01_pipeline/02_update_deployment_task.yaml

使用 oc get task 或者 tkn task list 命令列出创建的任务:

[root@bastion 01_pipeline]# oc get task
NAME                AGE
apply-manifests     22s
update-deployment   17s

[root@bastion 01_pipeline]# tkn task list
NAME                DESCRIPTION   AGE
apply-manifests                   5 minutes ago
update-deployment                 5 minutes ago

输出会确认创建了 apply-manifests 和 update-deployment 任务

使用 tkn clustertasks list 命令列出由 Operator 安装的额外集群任务,如 buildah 和 s2i-python-3

[root@bastion 01_pipeline]# tkn clustertasks list
NAME                       DESCRIPTION              AGE
buildah                    Buildah task builds...   1 day ago
buildah-pr                 Buildah task builds...   1 day ago
buildah-pr-v0-19-0         Buildah task builds...   1 day ago
buildah-v0-19-0            Buildah task builds...   1 day ago
git-cli                    This task can be us...   8 hours ago
git-clone                  These Tasks are Git...   1 day ago
......

注意: 在内网环境使用 buildah 集群任务,您必须确保 Dockerfile 使用内部镜像流作为基础镜像。

创建存储,必须。
因为每个 task 都是通过pod 执行脚本,pod任务完成后的输出物需要通过存储流转到下一阶段,比如第一步拉取的代码,第二步需要编译,第三步需要做代码扫描。

~ oc create -f https://raw.githubusercontent.com/openshift/pipelines-tutorial/pipelines-1.3/01_pipeline/03_persistent_volume_claim.yaml 

~ oc get pvc
NAME         STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS          AGE
source-pvc   Bound    pvc-7c82cb22-65f1-47fb-9ed0-9c4ca5c4f992   500Mi      RWO            managed-nfs-storage   29s

3.3 组装 pipeline

pipeline 由多个 task 组成,设计成可以在多个场景下进行复用。

Pipeline 通过使用 from 和 runAfter 参数来指定在不同任务间如何进行交互以及它们执行的顺序。它使用 workspaces 字段指定 Pipeline 中每个任务在执行过程中所需的一个或多个卷。

导入示例 pipeline,此 pipeline 一共有4个阶段,是fetch-repository, build-image, apply-manifests, update-deployment,分别对应4个 task: git-clone(ClusterTask),buildah(ClusterTask),apply-manifests,update-deployment。 前两个task 是 operator 自带的,全局属性,后两个task是我们刚刚创建的,只在当前 project 生效。

oc create -f https://raw.githubusercontent.com/openshift/pipelines-tutorial/pipelines-1.3/01_pipeline/04_pipeline.yaml

# 内容解读
[root@bastion 01_pipeline]# cat 04_pipeline.yaml  
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: build-and-deploy
spec:
  workspaces:
  - name: shared-workspace
  params:
  - name: deployment-name    #与下文tasks 中的params.value 对应,
                            #此处将来可输入参数,回填到tasks.params.value
                            #如果通过web console 运行,params的内容能可视化输入与展示,类似 openshift Template
    type: string
    description: name of the deployment to be patched
  - name: git-url
    type: string
    description: url of the git repo for the code of deployment
  - name: git-revision
    type: string
    description: revision to be used from repo of the code for deployment
    default: "pipelines-1.3"  
  - name: IMAGE
    type: string
    description: image to be build from the code
  tasks:    #tasks.name 有几个就代表有几个步骤,此name为自定义
  - name: fetch-repository
    taskRef:
      name: git-clone  #指定使用的task,此处使用了ClusterTask
      kind: ClusterTask
    workspaces:
    - name: output  #与clustertask git-clone 中spec.workspace.name 一致
      workspace: shared-workspace  #与此yaml文件 spec.workspace.name 一致
    params:
    - name: url    #这个name 与 ClusterTask git-clone 中的 spec.params.name 一致,
                   #将来会把下面的value 回填到 ClusterTask 中进行执行
      value: $(params.git-url)   #与此yaml spec.params.name 
    - name: subdirectory
      value: ""
    - name: deleteExisting
      value: "true"
    - name: revision
      value: $(params.git-revision)
  - name: build-image
    taskRef:
      name: buildah
      kind: ClusterTask
    params:
    - name: TLSVERIFY
      value: "false"
    - name: IMAGE
      value: $(params.IMAGE)
    workspaces:
    - name: source    
      workspace: shared-workspace
    runAfter:    # 启动顺序
    - fetch-repository
  - name: apply-manifests
    taskRef:
      name: apply-manifests   #指定使用的task,没写ClusterTask就说明是当前project的task
    workspaces:
    - name: source
      workspace: shared-workspace
    runAfter:
    - build-image
  - name: update-deployment
    taskRef:
      name: update-deployment
    params:
    - name: deployment
      value: $(params.deployment-name)
    - name: IMAGE
      value: $(params.IMAGE)
    runAfter:
    - apply-manifests

Pipeline - spec.params.name 自定义,此name 输入的 value 将传输到 Pipeline - spec.tasks.params.value
Pipeline - spec.tasks.params.name 与 ClusterTask(Task)中 spec.params.name 一致,Pipeline - spec.tasks.params.value 内容传输到 ClusterTask(Task) 中spec.params.name 的 value。

此处是顺序比较简单,4个 task 一个接一个执行。

在 Web console - Pipelines 菜单可以可视化的查看与执行 pipeline。
可视化菜单功能为 Tech preview,stable 版本还看不到。

pipeline-build-and-deploy.png

3.4 执行 pipeline

上面是组装了一个 pipeline,包含了4个步骤,包括 clone代码-制作精细-发布应用,但是使用的代码库地址,应用名称还未定义,需要在执行 pipeline 时候自定义。

在 pipeline 详情页,点 Action - Start,会弹出输入参数页面,此处展示的内容就是 04_pipeline.yaml 中 spec.params 部分。
deployment-name: 输入发布应用的名称,自定义
git-url: git 代码库的地址 https://github.com/openshift/pipelines-vote-api.git
git-version: 分支名称
IMAGE: 制作镜像使用的名称,image-registry.openshift-image-registry.svc:5000/pipelines-tutorial/vote-api 依次是内部仓库地址,project-name,deployment-name
shared-workspace: 选择上面创建的 source-pvc

pipeline-build-and-deploy-run1.png

pipeline 执行过程,每个 task 都会通过 image 运行 pod 来执行,如果异常可以通过页面或者命令查看 taskrun 状态或者查看在运行pod的event 与log

第二步的日志,根据 git库目录下的 Dockerfile 制作镜像,并推送到内部仓库


pipeline-build-and-deploy-run2.png

第三步的日志,导入 git库 k8s目录的 deployment.yaml 和 service.yaml

pipeline-build-and-deploy-run3.png

第四步,把 deployment 的 image, 更新成第二步生成的image

除了通过页面,也可以通过 tkn 命令执行 pipeline ,会提示依次输入内容

[root@bastion 01_pipeline]# tkn pipeline list
NAME               AGE              LAST RUN                  STARTED          DURATION   STATUS
build-and-deploy   51 minutes ago   build-and-deploy-54nl08   12 minutes ago   ---        Running
[root@bastion 01_pipeline]# tkn pipeline start build-and-deploy 
? Value for param `deployment-name` of type `string`? 

我这是在线环境,离线环境会遇到镜像拉取的问题,参照官网。
https://access.redhat.com/documentation/zh-cn/openshift_container_platform/4.7/html/cicd/creating-applications-with-cicd-pipelines#op-mirroring-images-to-run-pipelines-in-restricted-environment_creating-applications-with-cicd-pipelines

过程中可以通过 oc get 及 tkn taskrun list 等查看状态。

[root@bastion 01_pipeline]# oc get pipelineruns.tekton.dev 
NAME                      SUCCEEDED   REASON    STARTTIME   COMPLETIONTIME
build-and-deploy-extr7w   Unknown     Running   3m12s       
[root@bastion 01_pipeline]# oc get taskruns.tekton.dev 
NAME                                             SUCCEEDED   REASON      STARTTIME   COMPLETIONTIME
build-and-deploy-extr7w-build-image-vr96v        Unknown     Running     61s         
build-and-deploy-extr7w-fetch-repository-7kpxj   True        Succeeded   3m19s       61s
[root@bastion 01_pipeline]# oc get pod
NAME                                                       READY   STATUS      RESTARTS   AGE
build-and-deploy-extr7w-build-image-vr96v-pod-hljx8        3/3     Running     0          63s
build-and-deploy-extr7w-fetch-repository-7kpxj-pod-dbjsh   0/1     Completed   0          3m21s

完成后,vote-api 后端服务发布完成

[root@bastion 01_pipeline]# oc get pod
NAME                                                        READY   STATUS      RESTARTS   AGE
build-and-deploy-47rwhl-apply-manifests-z9mv6-pod-sq5rg     0/1     Completed   0          8m39s
build-and-deploy-47rwhl-build-image-h8kbl-pod-ztwc2         0/3     Completed   0          11m
build-and-deploy-47rwhl-fetch-repository-c5gjx-pod-f95w8    0/1     Completed   0          11m
build-and-deploy-47rwhl-update-deployment-ztzm5-pod-zm4vl   0/1     Completed   0          7m31s
vote-api-c458c6d4f-52xm2                                    1/1     Running     0          7m26s
[root@bastion 01_pipeline]# tkn taskrun list
NAME                                              STARTED          DURATION     STATUS
build-and-deploy-47rwhl-update-deployment-ztzm5   13 minutes ago   4 seconds    Succeeded
build-and-deploy-47rwhl-apply-manifests-z9mv6     14 minutes ago   1 minute     Succeeded
build-and-deploy-47rwhl-build-image-h8kbl         16 minutes ago   2 minutes    Succeeded
build-and-deploy-47rwhl-fetch-repository-c5gjx    17 minutes ago   13 seconds   Succeeded

接着按照同样方法发布前端 vote-ui ,这次用 tkn 命令发布

tkn pipeline start build-and-deploy \
    -w name=shared-workspace,volumeClaimTemplateFile=https://raw.githubusercontent.com/openshift/pipelines-tutorial/pipelines-1.3/01_pipeline/03_persistent_volume_claim.yaml \
    -p deployment-name=vote-ui \
    -p git-url=https://github.com/openshift-pipelines/vote-ui.git \
    -p IMAGE=image-registry.openshift-image-registry.svc:5000/pipelines-tutorial/vote-ui

volumeClaimTemplateFile 参数会创建一个随意名称的pvc,和后端使用的不同,如果图形界面去 start vote-ui pipeline,也要提前手动创建不同的pvc。

查看前端域名,访问测试

[root@bastion 01_pipeline]# oc get route vote-ui --template='http://{{.spec.host}}'

http://vote-ui-pipelines-tutorial.apps.ocp4.example.com

vote-ui.png

其他 tkn 命令

# 查看已运行的 pipeline 
[root@bastion 01_pipeline]# tkn pipelinerun list
NAME                         STARTED          DURATION    STATUS
build-and-deploy-run-56kzg   15 minutes ago   5 minutes   Succeeded
build-and-deploy-47rwhl      5 hours ago      4 minutes   Succeeded

# 持续跟踪运行日志
[root@bastion 01_pipeline]# tkn pipelinerun logs build-and-deploy-run-56kzg -f

# 将最后运行的pipelinerun 再运行一次
tkn pipeline start build-and-deploy --last

也可以通过页面,对 pipelinerun 选择 Action -> rerun,再次运行。

总结

1.通过 operator 自带的 clustertask及自编task 组成 pipeline 模板,将常用参数提取为输入项。pipeline配合输入参数形成 pipelinerun 执行流水线工作。
每个task类似jenkins的stage。

2.每个task 都会用到独立的镜像,在离线环境需要提前准备好镜像,或通过 image mirror 方式。

3.demo中应用发布所使用到的yaml,存放在了git,便于管理。其实也可以通过 task 传递,可视化效果更好一些,就是参数会略显繁杂。

4.当前在页面通过start pipeline时候,pipelinerun的名称不能自定义,不利于区分pipelinerun属于哪个应用。

4. FAQ

4.1 task build-image 在 STEP-BUILD 失败,workspace目录找不到代码

启动pipeline 的时候,在Workspace->shared-workspace 没有指定存储。
需要指定已有pvc存储,因为每个task都是独立的pod。第一步拉取的代码的pod完成后,在第二步 build-image 的pod时候无法获取到。需要通过存储流转。

4.2 task build-image 在 STEP-PUSH fail,denied: requested access to the resource is denied

step-push
+ buildah --storage-driver=vfs push --tls-verify=false --digestfile /workspace/source/image-digest vote-api docker://vote-api
Getting image source signatures
Getting image source signatures
Getting image source signatures
Getting image source signatures
error copying layers and metadata from "containers-storage:[vfs@/var/lib/containers/storage+/var/run/containers/storage]localhost/vote-api:latest" to "docker://vote-api:latest": Error trying to reuse blob sha256:bd3ef8fb78ac28071075811b988706f81ea7f2be36c875592e87287921209862 at destination: Error checking whether a blob sha256:bd3ef8fb78ac28071075811b988706f81ea7f2be36c875592e87287921209862 exists in docker.io/library/vote-api: errors:
denied: requested access to the resource is denied
error parsing HTTP 401 response body: unexpected end of JSON input: ""

level=error msg="exit status 125"

step-digest-to-results
2021/04/14 06:00:23 Skipping step because a previous step failed

这是启动 pipeline 时候 IMAGE name 写的不对,如果IMAGE 只写 vote-api 就会出现上面的镜像,镜像就会默认推送到docker.io 当然会没有权限。
把IMAGE 写完整即可 image-registry.openshift-image-registry.svc:5000/pipelines-tutorial/vote-api

5. 参考链接

https://access.redhat.com/documentation/zh-cn/openshift_container_platform/4.7/html/cicd/creating-applications-with-cicd-pipelines

https://blog.csdn.net/weixin_43902588/article/details/103269747

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容